Skip to content

Commit

Permalink
feat: pwdless create account flow (#3964)
Browse files Browse the repository at this point in the history
  • Loading branch information
emilyjablonski authored Mar 20, 2024
1 parent 89478cc commit 4517900
Show file tree
Hide file tree
Showing 19 changed files with 767 additions and 650 deletions.
2 changes: 1 addition & 1 deletion api/prisma/seed-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const devSeeding = async (
const jurisdiction = await prismaClient.jurisdictions.create({
data: {
...jurisdictionFactory(jurisdictionName),
allowSingleUseCodeLogin: true,
allowSingleUseCodeLogin: false,
},
});
await prismaClient.userAccounts.create({
Expand Down
18 changes: 4 additions & 14 deletions api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export class AuthController {
@Request() req: ExpressRequest,
@Response({ passthrough: true }) res: ExpressResponse,
): Promise<SuccessDTO> {
return await this.authService.setCredentials(res, mapTo(User, req['user']));
return await this.authService.confirmAndSetCredentials(
mapTo(User, req['user']),
res,
);
}

@Get('logout')
Expand All @@ -90,19 +93,6 @@ 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
25 changes: 16 additions & 9 deletions api/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { PermissionTypeDecorator } from '../decorators/permission-type.decorator
import { UserFilterParams } from '../dtos/users/user-filter-params.dto';
import { UserCsvExporterService } from '../services/user-csv-export.service';
import { ExportLogInterceptor } from '../interceptors/export-log.interceptor';
import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto';
import { ThrottleGuard } from '../guards/throttler.guard';

@Controller('user')
Expand Down Expand Up @@ -170,13 +171,11 @@ export class UserController {
@Body() dto: UserCreate,
@Query() queryParams: UserCreateParams,
): Promise<User> {
const jurisdictionName = req.headers['jurisdictionname'] || '';
return await this.userService.create(
dto,
false,
queryParams.noWelcomeEmail !== true,
mapTo(User, req['user']),
jurisdictionName as string,
req,
);
}

Expand All @@ -189,12 +188,20 @@ export class UserController {
@Body() dto: UserInvite,
@Request() req: ExpressRequest,
): Promise<User> {
return await this.userService.create(
dto,
true,
undefined,
mapTo(User, req['user']),
);
return await this.userService.create(dto, true, undefined, req);
}

@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.userService.requestSingleUseCode(dto, req);
}

@Post('resend-confirmation')
Expand Down
104 changes: 22 additions & 82 deletions api/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import {
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { CookieOptions, Request, Response } from 'express';
import { CookieOptions, Response } from 'express';
import { sign, verify } from 'jsonwebtoken';
import { randomInt } from 'crypto';
import { Prisma } from '@prisma/client';
import { UpdatePassword } from '../dtos/auth/update-password.dto';
import { MfaType } from '../enums/mfa/mfa-type-enum';
Expand All @@ -19,11 +18,10 @@ import { PrismaService } from './prisma.service';
import { UserService } from './user.service';
import { IdDTO } from '../dtos/shared/id.dto';
import { mapTo } from '../utilities/mapTo';
import { generateSingleUseCode } from '../utilities/generate-single-use-code';
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';
Expand Down Expand Up @@ -220,7 +218,7 @@ export class AuthService {
}
}

const singleUseCode = this.generateSingleUseCode();
const singleUseCode = generateSingleUseCode();
await this.prisma.userAccounts.update({
data: {
singleUseCode,
Expand All @@ -246,76 +244,6 @@ 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 };
}

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 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 Expand Up @@ -399,14 +327,26 @@ export class AuthService {
}

/*
generates a numeric mfa code
confirms a user if using pwdless
*/
generateSingleUseCode() {
let out = '';
const characters = '0123456789';
for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) {
out += characters.charAt(randomInt(characters.length));
async confirmAndSetCredentials(
user: User,
res: Response,
): Promise<SuccessDTO> {
if (!user.confirmedAt) {
const data: Prisma.UserAccountsUpdateInput = {
confirmedAt: new Date(),
confirmationToken: null,
};

await this.prisma.userAccounts.update({
data,
where: {
id: user.id,
},
});
}
return out;

return await this.setCredentials(res, user);
}
}
108 changes: 96 additions & 12 deletions api/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import crypto from 'crypto';
import { verify, sign } from 'jsonwebtoken';
import { Request } from 'express';

import { PrismaService } from './prisma.service';
import { User } from '../dtos/users/user.dto';
import { mapTo } from '../utilities/mapTo';
Expand All @@ -37,6 +39,8 @@ import { permissionActions } from '../enums/permissions/permission-actions-enum'
import { buildWhereClause } from '../utilities/build-user-where';
import { getPublicEmailURL } from '../utilities/get-public-email-url';
import { UserRole } from '../dtos/users/user-role.dto';
import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto';
import { generateSingleUseCode } from '../utilities/generate-single-use-code';

/*
this is the service for users
Expand Down Expand Up @@ -479,9 +483,11 @@ export class UserService {
dto: UserCreate | UserInvite,
forPartners: boolean,
sendWelcomeEmail = false,
requestingUser: User,
jurisdictionName?: string,
req: Request,
): Promise<User> {
const requestingUser = mapTo(User, req['user']);
const jurisdictionName = (req.headers['jurisdictionname'] as string) || '';

if (
this.containsInvalidCharacters(dto.firstName) ||
this.containsInvalidCharacters(dto.lastName)
Expand Down Expand Up @@ -646,16 +652,27 @@ export class UserService {

// Public user that needs email
if (!forPartners && sendWelcomeEmail) {
const confirmationUrl = this.getPublicConfirmationUrl(
dto.appUrl,
confirmationToken,
);
this.emailService.welcome(
jurisdictionName,
mapTo(User, newUser),
dto.appUrl,
confirmationUrl,
);
const fullJurisdiction = await this.prisma.jurisdictions.findFirst({
where: {
name: jurisdictionName as string,
},
});

if (fullJurisdiction?.allowSingleUseCodeLogin) {
this.requestSingleUseCode(dto, req);
} else {
const confirmationUrl = this.getPublicConfirmationUrl(
dto.appUrl,
confirmationToken,
);
this.emailService.welcome(
jurisdictionName,
mapTo(User, newUser),
dto.appUrl,
confirmationUrl,
);
}

// Partner user that is given access to an additional jurisdiction
} else if (
forPartners &&
Expand Down Expand Up @@ -876,4 +893,71 @@ export class UserService {

return false;
}

/**
*
* @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 };
}

const jurisdictionName = req?.headers?.jurisdictionname;
if (!jurisdictionName) {
throw new BadRequestException(
'jurisdictionname is missing from the request headers',
);
}

const juris = await this.prisma.jurisdictions.findFirst({
select: {
id: true,
allowSingleUseCodeLogin: true,
},
where: {
name: jurisdictionName as string,
},
orderBy: {
allowSingleUseCodeLogin: OrderByEnum.DESC,
},
});

if (!juris) {
throw new BadRequestException(
`Jurisidiction ${jurisdictionName} does not exists`,
);
}

if (!juris.allowSingleUseCodeLogin) {
throw new BadRequestException(
`Single use code login is not setup for ${jurisdictionName}`,
);
}

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

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

return { success: true };
}
}
10 changes: 10 additions & 0 deletions api/src/utilities/generate-single-use-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { randomInt } from 'crypto';

export const generateSingleUseCode = () => {
let out = '';
const characters = '0123456789';
for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) {
out += characters.charAt(randomInt(characters.length));
}
return out;
};
Loading

0 comments on commit 4517900

Please sign in to comment.