Skip to content

Commit

Permalink
feat: new single use code login endpoint (#3928)
Browse files Browse the repository at this point in the history
* feat: new single use code login endpoint

* fix: updates per pr comments
  • Loading branch information
YazeedLoonat authored Mar 14, 2024
1 parent d54b9a6 commit 567381f
Show file tree
Hide file tree
Showing 13 changed files with 1,213 additions and 57 deletions.
17 changes: 17 additions & 0 deletions api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { Login } from '../dtos/auth/login.dto';
import { mapTo } from '../utilities/mapTo';
import { User } from '../dtos/users/user.dto';
import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto';
import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto';
import { SingleUseCodeAuthGuard } from '../guards/single-use-code.guard';

@Controller('auth')
@ApiTags('auth')
Expand All @@ -49,6 +51,21 @@ export class AuthController {
return await this.authService.setCredentials(res, mapTo(User, req['user']));
}

@Post('loginViaSingleUseCode')
@ApiOperation({
summary: 'LoginViaSingleUseCode',
operationId: 'login via a single use code',
})
@ApiOkResponse({ type: SuccessDTO })
@ApiBody({ type: LoginViaSingleUseCode })
@UseGuards(SingleUseCodeAuthGuard)
async loginViaSingleUseCode(
@Request() req: ExpressRequest,
@Response({ passthrough: true }) res: ExpressResponse,
): Promise<SuccessDTO> {
return await this.authService.setCredentials(res, mapTo(User, req['user']));
}

@Get('logout')
@ApiOperation({ summary: 'Logout', operationId: 'logout' })
@ApiOkResponse({ type: SuccessDTO })
Expand Down
19 changes: 19 additions & 0 deletions api/src/dtos/auth/login-single-use-code.dto.ts
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;
}
5 changes: 5 additions & 0 deletions api/src/guards/single-use-code.guard.ts
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') {}
9 changes: 8 additions & 1 deletion api/src/modules/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { UserModule } from './user.module';
import { MfaStrategy } from '../passports/mfa.strategy';
import { JwtStrategy } from '../passports/jwt.strategy';
import { EmailModule } from './email.module';
import { SingleUseCodeStrategy } from '../passports/single-use-code.strategy';

@Module({
imports: [
Expand All @@ -24,7 +25,13 @@ import { EmailModule } from './email.module';
EmailModule,
],
controllers: [AuthController],
providers: [AuthService, PermissionService, MfaStrategy, JwtStrategy],
providers: [
AuthService,
PermissionService,
MfaStrategy,
JwtStrategy,
SingleUseCodeStrategy,
],
exports: [AuthService, PermissionService],
})
export class AuthModule {}
60 changes: 24 additions & 36 deletions api/src/passports/mfa.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { Strategy } from 'passport-local';
import { Request } from 'express';
import { PassportStrategy } from '@nestjs/passport';
import {
HttpException,
HttpStatus,
Injectable,
UnauthorizedException,
ValidationPipe,
Expand All @@ -18,6 +16,11 @@ import {
import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options';
import { Login } from '../dtos/auth/login.dto';
import { MfaType } from '../enums/mfa/mfa-type-enum';
import {
isUserLockedOut,
singleUseCodePresent,
singleUseCodeValid,
} from '../utilities/passport-validator-utilities';

@Injectable()
export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
Expand Down Expand Up @@ -53,32 +56,14 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
throw new UnauthorizedException(
`user ${dto.email} attempted to log in, but does not exist`,
);
} else if (
rawUser.lastLoginAt &&
rawUser.failedLoginAttemptsCount >=
Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS)
) {
// if a user has logged in, but has since gone over their max failed login attempts
const retryAfter = new Date(
rawUser.lastLoginAt.getTime() +
Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN),
);
if (retryAfter <= new Date()) {
// if we have passed the login lock TTL, reset login lock countdown
rawUser.failedLoginAttemptsCount = 0;
} else {
// if the login lock is still a valid lock, error
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message: 'Failed login attempts exceeded.',
retryAfter,
},
429,
);
}
} else if (!rawUser.confirmedAt) {
}
isUserLockedOut(
rawUser.lastLoginAt,
rawUser.failedLoginAttemptsCount,
Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS),
Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN),
);
if (!rawUser.confirmedAt) {
// if user is not confirmed already
throw new UnauthorizedException(
`user ${rawUser.id} attempted to login, but is not confirmed`,
Expand Down Expand Up @@ -114,9 +99,11 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {

let authSuccess = true;
if (
!dto.mfaCode ||
!rawUser.singleUseCode ||
!rawUser.singleUseCodeUpdatedAt
!singleUseCodePresent(
dto.mfaCode,
rawUser.singleUseCode,
rawUser.singleUseCodeUpdatedAt,
)
) {
// if an mfaCode was not sent, and a singleUseCode wasn't stored in the db for the user
// signal to the front end to request an mfa code
Expand All @@ -125,11 +112,12 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') {
name: 'mfaCodeIsMissing',
});
} else if (
new Date(
rawUser.singleUseCodeUpdatedAt.getTime() +
Number(process.env.MFA_CODE_VALID),
) < new Date() ||
rawUser.singleUseCode !== dto.mfaCode
singleUseCodeValid(
rawUser.singleUseCodeUpdatedAt,
Number(process.env.MFA_CODE_VALID),
dto.mfaCode,
rawUser.singleUseCode,
)
) {
// if mfaCode TTL has expired, or if the mfa code input was incorrect
authSuccess = false;
Expand Down
197 changes: 197 additions & 0 deletions api/src/passports/single-use-code.strategy.ts
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,
},
});
}
}
Loading

0 comments on commit 567381f

Please sign in to comment.