-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new single use code login endpoint (#3928)
* feat: new single use code login endpoint * fix: updates per pr comments
- Loading branch information
1 parent
d54b9a6
commit 567381f
Showing
13 changed files
with
1,213 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { IsEmail, IsString, MaxLength } from 'class-validator'; | ||
import { Expose } from 'class-transformer'; | ||
import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; | ||
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
|
||
export class LoginViaSingleUseCode { | ||
@Expose() | ||
@IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) | ||
@EnforceLowerCase() | ||
@ApiProperty() | ||
email: string; | ||
|
||
@Expose() | ||
@IsString({ groups: [ValidationsGroupsEnum.default] }) | ||
@MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) | ||
@ApiProperty() | ||
singleUseCode: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { AuthGuard } from '@nestjs/passport'; | ||
|
||
@Injectable() | ||
export class SingleUseCodeAuthGuard extends AuthGuard('single-use-code') {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
import { Strategy } from 'passport-local'; | ||
import { Request } from 'express'; | ||
import { PassportStrategy } from '@nestjs/passport'; | ||
import { | ||
BadRequestException, | ||
Injectable, | ||
UnauthorizedException, | ||
ValidationPipe, | ||
} from '@nestjs/common'; | ||
import { User } from '../dtos/users/user.dto'; | ||
import { PrismaService } from '../services/prisma.service'; | ||
import { mapTo } from '../utilities/mapTo'; | ||
import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; | ||
import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; | ||
import { OrderByEnum } from '../enums/shared/order-by-enum'; | ||
import { | ||
isUserLockedOut, | ||
singleUseCodePresent, | ||
singleUseCodeValid, | ||
} from '../utilities/passport-validator-utilities'; | ||
|
||
@Injectable() | ||
export class SingleUseCodeStrategy extends PassportStrategy( | ||
Strategy, | ||
'single-use-code', | ||
) { | ||
constructor(private prisma: PrismaService) { | ||
super({ | ||
usernameField: 'email', | ||
passwordField: 'singleUseCode', | ||
passReqToCallback: true, | ||
}); | ||
} | ||
|
||
/* | ||
verifies that the incoming log in information is valid | ||
returns the verified user | ||
*/ | ||
async validate(req: Request): Promise<User> { | ||
const validationPipe = new ValidationPipe(defaultValidationPipeOptions); | ||
const dto: LoginViaSingleUseCode = await validationPipe.transform( | ||
req.body, | ||
{ | ||
type: 'body', | ||
metatype: LoginViaSingleUseCode, | ||
}, | ||
); | ||
const jurisName = req?.headers?.jurisdictionname; | ||
if (!jurisName) { | ||
throw new BadRequestException( | ||
'jurisdictionname is missing from the request headers', | ||
); | ||
} | ||
|
||
const juris = await this.prisma.jurisdictions.findFirst({ | ||
select: { | ||
id: true, | ||
allowSingleUseCodeLogin: true, | ||
}, | ||
where: { | ||
name: jurisName as string, | ||
}, | ||
orderBy: { | ||
allowSingleUseCodeLogin: OrderByEnum.DESC, | ||
}, | ||
}); | ||
if (!juris) { | ||
throw new BadRequestException( | ||
`Jurisidiction ${jurisName} does not exists`, | ||
); | ||
} | ||
|
||
if (!juris.allowSingleUseCodeLogin) { | ||
throw new BadRequestException( | ||
`Single use code login is not setup for ${jurisName}`, | ||
); | ||
} | ||
|
||
const rawUser = await this.prisma.userAccounts.findFirst({ | ||
include: { | ||
userRoles: true, | ||
listings: true, | ||
jurisdictions: true, | ||
}, | ||
where: { | ||
email: dto.email, | ||
}, | ||
}); | ||
if (!rawUser) { | ||
throw new UnauthorizedException( | ||
`user ${dto.email} attempted to log in, but does not exist`, | ||
); | ||
} | ||
|
||
isUserLockedOut( | ||
rawUser.lastLoginAt, | ||
rawUser.failedLoginAttemptsCount, | ||
Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS), | ||
Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), | ||
); | ||
|
||
let authSuccess = true; | ||
if ( | ||
!singleUseCodePresent( | ||
dto.singleUseCode, | ||
rawUser.singleUseCode, | ||
rawUser.singleUseCodeUpdatedAt, | ||
) | ||
) { | ||
// if a singleUseCode was not sent, or a singleUseCode wasn't stored in the db for the user | ||
// signal to the front end to request an single use code | ||
await this.updateFailedLoginCount(0, rawUser.id); | ||
throw new UnauthorizedException({ | ||
name: 'singleUseCodeIsMissing', | ||
}); | ||
} else if ( | ||
singleUseCodeValid( | ||
rawUser.singleUseCodeUpdatedAt, | ||
Number(process.env.MFA_CODE_VALID), | ||
dto.singleUseCode, | ||
rawUser.singleUseCode, | ||
) | ||
) { | ||
// if singleUseCode TTL has expired, or if the code input was incorrect | ||
authSuccess = false; | ||
} else { | ||
// if login was a success | ||
rawUser.singleUseCode = null; | ||
rawUser.singleUseCodeUpdatedAt = new Date(); | ||
} | ||
|
||
if (!authSuccess) { | ||
// if we failed login validation | ||
rawUser.failedLoginAttemptsCount += 1; | ||
await this.updateStoredUser( | ||
rawUser.singleUseCode, | ||
rawUser.singleUseCodeUpdatedAt, | ||
rawUser.failedLoginAttemptsCount, | ||
rawUser.id, | ||
); | ||
throw new UnauthorizedException({ | ||
message: 'singleUseCodeUnauthorized', | ||
failureCountRemaining: | ||
Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) + | ||
1 - | ||
rawUser.failedLoginAttemptsCount, | ||
}); | ||
} | ||
|
||
// if the password and single use code was valid | ||
rawUser.failedLoginAttemptsCount = 0; | ||
|
||
await this.updateStoredUser( | ||
rawUser.singleUseCode, | ||
rawUser.singleUseCodeUpdatedAt, | ||
rawUser.failedLoginAttemptsCount, | ||
rawUser.id, | ||
); | ||
return mapTo(User, rawUser); | ||
} | ||
|
||
async updateFailedLoginCount(count: number, userId: string): Promise<void> { | ||
let lastLoginAt = undefined; | ||
if (count === 1) { | ||
// if the count went from 0 -> 1 then we update the lastLoginAt so the count of failed attempts falls off properly | ||
lastLoginAt = new Date(); | ||
} | ||
await this.prisma.userAccounts.update({ | ||
data: { | ||
failedLoginAttemptsCount: count, | ||
lastLoginAt, | ||
}, | ||
where: { | ||
id: userId, | ||
}, | ||
}); | ||
} | ||
|
||
async updateStoredUser( | ||
singleUseCode: string, | ||
singleUseCodeUpdatedAt: Date, | ||
failedLoginAttemptsCount: number, | ||
userId: string, | ||
): Promise<void> { | ||
await this.prisma.userAccounts.update({ | ||
data: { | ||
singleUseCode, | ||
singleUseCodeUpdatedAt, | ||
failedLoginAttemptsCount, | ||
lastLoginAt: new Date(), | ||
}, | ||
where: { | ||
id: userId, | ||
}, | ||
}); | ||
} | ||
} |
Oops, something went wrong.