Skip to content

Commit

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

* fix: updating mocks

* fix: update per Em and Emily

* fix: updates after convo with eric

* fix: updates to tests
  • Loading branch information
YazeedLoonat authored Mar 6, 2024
1 parent ff479ab commit e5bf474
Show file tree
Hide file tree
Showing 15 changed files with 494 additions and 9 deletions.
2 changes: 1 addition & 1 deletion api/prisma/migrations/00_init/migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1292,4 +1292,4 @@ ALTER TABLE
ADD
CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON
DELETE CASCADE ON
UPDATE CASCADE;
UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Adds new single-use code translations for emails.

UPDATE translations
SET translations = jsonb_set(translations, '{singleUseCodeEmail}', '{"greeting": "Hi","message": "Use the following code to sign in to your %{jurisdictionName} account. This code will be valid for 5 minutes. Never share this code.","singleUseCode": "%{singleUseCode}"}')
WHERE jurisdiction_id IS NULL
and language = 'en';

UPDATE translations
SET translations = jsonb_set(translations, '{mfaCodeEmail, mfaCode}', '{"mfaCode": "Your access code is: %{singleUseCode}"}')
WHERE language = 'en';
10 changes: 8 additions & 2 deletions api/prisma/seed-helpers/translation-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const translations = (jurisdictionName?: string) => ({
},
header: {
logoUrl:
'https://res.cloudinary.com/exygy/image/upload/v1692118607/core/bloom_housing_logo.png',
'https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/dev/bloom_logo_generic_zgb4sg.jpg',
logoTitle: 'Bloom Housing Portal',
},
invite: {
Expand Down Expand Up @@ -104,7 +104,7 @@ const translations = (jurisdictionName?: string) => ({
},
mfaCodeEmail: {
message: 'Access code for your account has been requested.',
mfaCode: 'Your access code is: %{mfaCode}',
mfaCode: 'Your access code is: %{singleUseCode}',
},
forgotPassword: {
subject: 'Forgot your password?',
Expand Down Expand Up @@ -146,6 +146,12 @@ const translations = (jurisdictionName?: string) => ({
hello: 'Hello,',
title: '%{title}',
},
singleUseCodeEmail: {
greeting: 'Hi',
message:
'Use the following code to sign in to your %{jurisdictionName} account. This code will be valid for 5 minutes. Never share this code.',
singleUseCode: '%{singleUseCode}',
},
});

export const translationFactory = (
Expand Down
14 changes: 14 additions & 0 deletions api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { OptionalAuthGuard } from '../guards/optional.guard';
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';

@Controller('auth')
@ApiTags('auth')
Expand Down Expand Up @@ -71,6 +72,19 @@ export class AuthController {
return await this.authService.requestMfaCode(dto);
}

@Post('request-single-use-code')
@ApiOperation({
summary: 'Request single use code',
operationId: 'requestSingleUseCode',
})
@ApiOkResponse({ type: SuccessDTO })
async requestSingleUseCode(
@Request() req: ExpressRequest,
@Body() dto: RequestSingleUseCode,
): Promise<SuccessDTO> {
return await this.authService.requestSingleUseCode(dto, req);
}

@Get('requestNewToken')
@ApiOperation({
summary: 'Requests a new token given a refresh token',
Expand Down
13 changes: 13 additions & 0 deletions api/src/dtos/single-use-code/request-single-use-code.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsEmail } from 'class-validator';
import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';

export class RequestSingleUseCode {
@Expose()
@IsEmail({}, { groups: [ValidationsGroupsEnum.default] })
@EnforceLowerCase()
@ApiProperty()
email: string;
}
63 changes: 61 additions & 2 deletions api/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import {
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Response } from 'express';
import { CookieOptions } from 'express';
import { CookieOptions, Request, Response } from 'express';
import { sign, verify } from 'jsonwebtoken';
import { randomInt } from 'crypto';
import { Prisma } from '@prisma/client';
Expand All @@ -23,6 +22,7 @@ import { mapTo } from '../utilities/mapTo';
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';

// 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';
Expand Down Expand Up @@ -245,6 +245,65 @@ export class AuthService {
};
}

/**
*
* @param dto the incoming request with the email
* @returns a SuccessDTO always, and if the user exists it will send a code to the requester
*/
async requestSingleUseCode(
dto: RequestSingleUseCode,
req: Request,
): Promise<SuccessDTO> {
const user = await this.prisma.userAccounts.findFirst({
where: { email: dto.email },
include: {
jurisdictions: true,
},
});
if (!user) {
return { success: true };
}

if (!req?.headers?.jurisdictionname) {
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],
},
allowSingleUseCodeLogin: true,
},
});
if (!juris) {
throw new BadRequestException(
'Single use code login is not setup for this jurisdiction',
);
}

const singleUseCode = this.generateSingleUseCode();
await this.prisma.userAccounts.update({
data: {
singleUseCode,
singleUseCodeUpdatedAt: new Date(),
},
where: {
id: user.id,
},
});

await this.emailsService.sendSingleUseCode(
mapTo(User, user),
singleUseCode,
);

return { success: true };
}

/*
updates a user's password and logs them in
*/
Expand Down
23 changes: 23 additions & 0 deletions api/src/services/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,29 @@ export class EmailService {
);
}

public async sendSingleUseCode(user: User, singleUseCode: string) {
const jurisdiction = await this.getJurisdiction(user.jurisdictions);
void (await this.loadTranslations(jurisdiction, user.language));
const emailFromAddress = await this.getEmailToSendFrom(
user.jurisdictions,
jurisdiction,
);
await this.send(
user.email,
emailFromAddress,
user.confirmedAt
? `Code for your ${jurisdiction.name} sign-in`
: `${jurisdiction.name} verification code`,
this.template('single-use-code')({
user: user,
singleUseCodeOptions: {
singleUseCode,
jurisdictionName: jurisdiction.name,
},
}),
);
}

public async applicationConfirmation(
listing: Listing,
application: ApplicationCreate,
Expand Down
9 changes: 9 additions & 0 deletions api/src/views/single-use-code.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<p>{{t "singleUseCodeEmail.greeting"}} {{> user-name}},</p>
<p>
{{t "singleUseCodeEmail.message" singleUseCodeOptions }}
</p>
<br />
<p>
{{t "singleUseCodeEmail.singleUseCode" singleUseCodeOptions}}
</p>
{{> simple-footer }}
110 changes: 110 additions & 0 deletions api/test/integration/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
TOKEN_COOKIE_NAME,
} from '../../src/services/auth.service';
import { SmsService } from '../../src/services/sms.service';
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';
Expand All @@ -22,6 +23,7 @@ describe('Auth Controller Tests', () => {
let app: INestApplication;
let prisma: PrismaService;
let smsService: SmsService;
let emailService: EmailService;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
Expand All @@ -32,6 +34,7 @@ describe('Auth Controller Tests', () => {
app.use(cookieParser());
prisma = moduleFixture.get<PrismaService>(PrismaService);
smsService = moduleFixture.get<SmsService>(SmsService);
emailService = moduleFixture.get<EmailService>(EmailService);
await app.init();
});

Expand Down Expand Up @@ -344,4 +347,111 @@ describe('Auth Controller Tests', () => {
.set('Cookie', resLogIn.headers['set-cookie'])
.expect(200);
});

it('should request single use code successfully', async () => {
const storedUser = await prisma.userAccounts.create({
data: await userFactory({
roles: { isAdmin: true },
mfaEnabled: true,
confirmedAt: new Date(),
phoneNumber: '111-111-1111',
phoneNumberVerified: true,
}),
});

const jurisdiction = await prisma.jurisdictions.create({
data: {
name: 'single_use_code_1',
allowSingleUseCodeLogin: true,
rentalAssistanceDefault: 'test',
},
});
emailService.sendSingleUseCode = jest.fn();

const res = await request(app.getHttpServer())
.post('/auth/request-single-use-code')
.send({
email: storedUser.email,
} as RequestMfaCode)
.set({ jurisdictionname: jurisdiction.name })
.expect(201);

expect(res.body).toEqual({ success: true });

expect(emailService.sendSingleUseCode).toHaveBeenCalled();

const user = await prisma.userAccounts.findUnique({
where: {
id: storedUser.id,
},
});

expect(user.singleUseCode).not.toBeNull();
expect(user.singleUseCodeUpdatedAt).not.toBeNull();
});

it('should request single use code, but jurisdiction does not allow', async () => {
const storedUser = await prisma.userAccounts.create({
data: await userFactory({
roles: { isAdmin: true },
mfaEnabled: true,
confirmedAt: new Date(),
phoneNumber: '111-111-1111',
phoneNumberVerified: true,
}),
});

const jurisdiction = await prisma.jurisdictions.create({
data: {
name: 'single_use_code_2',
allowSingleUseCodeLogin: false,
rentalAssistanceDefault: 'test',
},
});
emailService.sendSingleUseCode = jest.fn();

const res = await request(app.getHttpServer())
.post('/auth/request-single-use-code')
.send({
email: storedUser.email,
} 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',
);

expect(emailService.sendSingleUseCode).not.toHaveBeenCalled();

const user = await prisma.userAccounts.findUnique({
where: {
id: storedUser.id,
},
});

expect(user.singleUseCode).toBeNull();
});

it('should request single use code, but user does not exist', async () => {
const jurisdiction = await prisma.jurisdictions.create({
data: {
name: 'single_use_code_3',
allowSingleUseCodeLogin: true,
rentalAssistanceDefault: 'test',
},
});
emailService.sendSingleUseCode = jest.fn();

const res = await request(app.getHttpServer())
.post('/auth/request-single-use-code')
.send({
email: '[email protected]',
} as RequestMfaCode)
.set({ jurisdictionname: jurisdiction.name })
.expect(201);
expect(res.body.success).toEqual(true);

expect(emailService.sendSingleUseCode).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit e5bf474

Please sign in to comment.