diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 59c3fc1fdf..3861e1c807 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -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') @@ -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 { + return await this.authService.setCredentials(res, mapTo(User, req['user'])); + } + @Get('logout') @ApiOperation({ summary: 'Logout', operationId: 'logout' }) @ApiOkResponse({ type: SuccessDTO }) diff --git a/api/src/dtos/auth/login-single-use-code.dto.ts b/api/src/dtos/auth/login-single-use-code.dto.ts new file mode 100644 index 0000000000..f39a8ffbf7 --- /dev/null +++ b/api/src/dtos/auth/login-single-use-code.dto.ts @@ -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; +} diff --git a/api/src/guards/single-use-code.guard.ts b/api/src/guards/single-use-code.guard.ts new file mode 100644 index 0000000000..109488231c --- /dev/null +++ b/api/src/guards/single-use-code.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class SingleUseCodeAuthGuard extends AuthGuard('single-use-code') {} diff --git a/api/src/modules/auth.module.ts b/api/src/modules/auth.module.ts index 13ff160ec5..9ba4f939e5 100644 --- a/api/src/modules/auth.module.ts +++ b/api/src/modules/auth.module.ts @@ -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: [ @@ -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 {} diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index 2101e289bc..5afb8f078b 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -2,8 +2,6 @@ import { Strategy } from 'passport-local'; import { Request } from 'express'; import { PassportStrategy } from '@nestjs/passport'; import { - HttpException, - HttpStatus, Injectable, UnauthorizedException, ValidationPipe, @@ -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') { @@ -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`, @@ -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 @@ -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; diff --git a/api/src/passports/single-use-code.strategy.ts b/api/src/passports/single-use-code.strategy.ts new file mode 100644 index 0000000000..fdce589101 --- /dev/null +++ b/api/src/passports/single-use-code.strategy.ts @@ -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 { + 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 { + 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 { + await this.prisma.userAccounts.update({ + data: { + singleUseCode, + singleUseCodeUpdatedAt, + failedLoginAttemptsCount, + lastLoginAt: new Date(), + }, + where: { + id: userId, + }, + }); + } +} diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index efce9728eb..2b98b2c342 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -23,6 +23,7 @@ import { Confirm } from '../dtos/auth/confirm.dto'; import { SmsService } from './sms.service'; import { EmailService } from './email.service'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; // since our local env doesn't have an https cert we can't be secure. Hosted envs should be secure const secure = process.env.NODE_ENV !== 'development'; @@ -264,24 +265,35 @@ export class AuthService { return { success: true }; } - if (!req?.headers?.jurisdictionname) { + const jurisName = req?.headers?.jurisdictionname; + if (!jurisName) { throw new BadRequestException( 'jurisdictionname is missing from the request headers', ); } - const jurisName = req.headers['jurisdictionname']; const juris = await this.prisma.jurisdictions.findFirst({ - where: { - name: { - in: Array.isArray(jurisName) ? jurisName : [jurisName], - }, + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: jurisName as string, + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); + if (!juris) { throw new BadRequestException( - 'Single use code login is not setup for this jurisdiction', + `Jurisidiction ${jurisName} does not exists`, + ); + } + + if (!juris.allowSingleUseCodeLogin) { + throw new BadRequestException( + `Single use code login is not setup for ${jurisName}`, ); } diff --git a/api/src/utilities/passport-validator-utilities.ts b/api/src/utilities/passport-validator-utilities.ts new file mode 100644 index 0000000000..cd68af8c9a --- /dev/null +++ b/api/src/utilities/passport-validator-utilities.ts @@ -0,0 +1,71 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * + * @param lastLoginAt the last time the user logged in (stored in db) + * @param failedLoginAttemptsCount the number of times the user failed to log in (stored in db) + * @param maxAttempts the maximum number of attempts before user is considered locked out (env variable) + * + * @returns throws error if user is already locked out + */ +export function isUserLockedOut( + lastLoginAt: Date, + failedLoginAttemptsCount: number, + maxAttempts: number, + cooldown: number, +): void { + if (lastLoginAt && failedLoginAttemptsCount >= maxAttempts) { + // if a user has logged in, but has since gone over their max failed login attempts + const retryAfter = new Date(lastLoginAt.getTime() + cooldown); + if (retryAfter <= new Date()) { + // if we have passed the login lock TTL, reset login lock countdown + 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, + ); + } + } +} + +/** + * + * @param incomingSingleUseCode single use code that was sent as part of the request + * @param storedSingleUseCode single use code that is stored in the db for this user + * @param singleUseCodeUpdatedAt last time a single use code was set for a user (stord in db) + * @returns true if all params are present + */ +export function singleUseCodePresent( + incomingSingleUseCode: string, + storedSingleUseCode: string, + singleUseCodeUpdatedAt: Date, +) { + return incomingSingleUseCode && storedSingleUseCode && singleUseCodeUpdatedAt; +} + +/** + * + * @param singleUseCodeUpdatedAt last time a single use code was set for a user (stored in db) + * @param ttl how long the single use code should stay active (env variable) + * @param incomingSingleUseCode single use code passed in as part of the request + * @param storedSingleUseCode single use code stored on the user + * @returns + */ +export function singleUseCodeValid( + singleUseCodeUpdatedAt: Date, + ttl: number, + incomingSingleUseCode: string, + storedSingleUseCode: string, +): boolean { + return ( + new Date(singleUseCodeUpdatedAt.getTime() + ttl) < new Date() || + storedSingleUseCode !== incomingSingleUseCode + ); +} diff --git a/api/test/integration/auth.e2e-spec.ts b/api/test/integration/auth.e2e-spec.ts index 77021dfffa..5810e2e9ad 100644 --- a/api/test/integration/auth.e2e-spec.ts +++ b/api/test/integration/auth.e2e-spec.ts @@ -18,6 +18,7 @@ import { EmailService } from '../../src/services/email.service'; import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; import { UpdatePassword } from '../../src/dtos/auth/update-password.dto'; import { Confirm } from '../../src/dtos/auth/confirm.dto'; +import { LoginViaSingleUseCode } from 'src/dtos/auth/login-single-use-code.dto'; describe('Auth Controller Tests', () => { let app: INestApplication; @@ -417,9 +418,9 @@ describe('Auth Controller Tests', () => { } as RequestMfaCode) .set({ jurisdictionname: jurisdiction.name }) .expect(400); - console.log('420:', res.body); + expect(res.body.message).toEqual( - 'Single use code login is not setup for this jurisdiction', + 'Single use code login is not setup for single_use_code_2', ); expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); @@ -454,4 +455,55 @@ describe('Auth Controller Tests', () => { expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); }); + + it('should login successfully through single use code', async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_login_test', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + singleUseCode: 'abcdef', + mfaEnabled: true, + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + }), + }); + const res = await request(app.getHttpServer()) + .post('/auth/loginViaSingleUseCode') + .send({ + email: storedUser.email, + singleUseCode: storedUser.singleUseCode, + } as LoginViaSingleUseCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + + expect(res.body).toEqual({ + success: true, + }); + + const cookies = res.headers['set-cookie'].map( + (cookie) => cookie.split('=')[0], + ); + + expect(cookies).toContain(TOKEN_COOKIE_NAME); + expect(cookies).toContain(REFRESH_COOKIE_NAME); + expect(cookies).toContain(ACCESS_TOKEN_AVAILABLE_NAME); + + const loggedInUser = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(loggedInUser.lastLoginAt).not.toBeNull(); + expect(loggedInUser.singleUseCode).toBeNull(); + expect(loggedInUser.activeAccessToken).not.toBeNull(); + expect(loggedInUser.activeRefreshToken).not.toBeNull(); + }); }); diff --git a/api/test/unit/passports/single-use-code.strategy.spec.ts b/api/test/unit/passports/single-use-code.strategy.spec.ts new file mode 100644 index 0000000000..f230f3b000 --- /dev/null +++ b/api/test/unit/passports/single-use-code.strategy.spec.ts @@ -0,0 +1,683 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { randomUUID } from 'crypto'; +import { Request } from 'express'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { passwordToHash } from '../../../src/utilities/password-helpers'; +import { SingleUseCodeStrategy } from '../../../src/passports/single-use-code.strategy'; +import { LoginViaSingleUseCode } from '../../../src/dtos/auth/login-single-use-code.dto'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; + +describe('Testing single-use-code strategy', () => { + let strategy: SingleUseCodeStrategy; + let prisma: PrismaService; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SingleUseCodeStrategy, PrismaService], + }).compile(); + + strategy = module.get(SingleUseCodeStrategy); + prisma = module.get(PrismaService); + }); + + it('should fail because user does not exist', async () => { + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError( + `user example@exygy.com attempted to log in, but does not exist`, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail because user is locked out', async () => { + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + lastLoginAt: new Date(), + failedLoginAttemptsCount: 10, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Failed login attempts exceeded.`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCode is stored', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCodeUpdatedAt is stored', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCode: 'zyxwv', + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCode is sent', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCode: 'zyxwv', + mfaCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if singleUseCode is incorrect', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv1', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`singleUseCodeUnauthorized`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 1, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if singleUseCode is expired', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`singleUseCodeUnauthorized`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 1, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction does not exist', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Jurisidiction juris 1 does not exists`); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Single use code login is not setup for juris 1`); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction is missing from header', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError( + `jurisdictionname is missing from the request headers`, + ); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); + }); + + it('should succeed', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await strategy.validate(request as unknown as Request); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: null, + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); +}); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index 198d04a062..ee8a1f702e 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -29,6 +29,7 @@ import { JurisdictionService } from '../../../src/services/jurisdiction.service' import { GoogleTranslateService } from '../../../src/services/google-translate.service'; import { PermissionService } from '../../../src/services/permission.service'; import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; describe('Testing auth service', () => { let authService: AuthService; @@ -871,7 +872,7 @@ describe('Testing auth service', () => { }); }); - it('should request single use code but jurisdiction does not allow', async () => { + it('should request single use code but jurisdiction does not exist', async () => { const id = randomUUID(); emailService.sendSingleUseCode = jest.fn(); prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ @@ -890,9 +891,7 @@ describe('Testing auth service', () => { }, { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, ), - ).rejects.toThrowError( - 'Single use code login is not setup for this jurisdiction', - ); + ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ where: { @@ -903,12 +902,64 @@ describe('Testing auth service', () => { }, }); expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, where: { - name: { - in: ['juris 1'], - }, + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await authService.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Single use code login is not setup for juris 1'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); expect(prisma.userAccounts.update).not.toHaveBeenCalled(); expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); @@ -960,6 +1011,7 @@ describe('Testing auth service', () => { }); prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ id, + allowSingleUseCodeLogin: true, }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id, @@ -981,12 +1033,16 @@ describe('Testing auth service', () => { }, }); expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - where: { - name: { - in: ['juris 1'], - }, + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index bf340e8fd8..0ef9379809 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -73,6 +73,7 @@ type ContextProps = { mfaType: MfaType, phoneNumber?: string ) => Promise + loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise } // Internal Provider State @@ -239,6 +240,24 @@ export const AuthProvider: FunctionComponent = ({ child dispatch(stopLoading()) } }, + loginViaSingleUseCode: async (email, singleUseCode) => { + dispatch(startLoading()) + try { + const response = await authService?.loginViaASingleUseCode({ + body: { email, singleUseCode }, + }) + if (response) { + const profile = await userService?.profile() + if (profile) { + dispatch(saveProfile(profile)) + return profile + } + } + return undefined + } finally { + dispatch(stopLoading()) + } + }, signOut: async () => { await authService.logout() dispatch(saveProfile(null)) diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index a697d72111..18d0892082 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1814,6 +1814,28 @@ export class AuthService { axios(configs, resolve, reject) }) } + /** + * LoginViaSingleUseCode + */ + loginViaASingleUseCode( + params: { + /** requestBody */ + body?: LoginViaSingleUseCode + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/loginViaSingleUseCode" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Logout */ @@ -5058,6 +5080,14 @@ export interface Login { mfaType?: MfaType } +export interface LoginViaSingleUseCode { + /** */ + email: string + + /** */ + singleUseCode: string +} + export interface RequestMfaCode { /** */ email: string