Skip to content

Commit

Permalink
update auth to use cognito client secrets (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
huang0h authored Oct 13, 2024
1 parent 0d4992b commit e17079c
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 109 deletions.
34 changes: 31 additions & 3 deletions apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Post,
Request,
UseGuards,
} from '@nestjs/common';

import { SignInDto } from './dtos/sign-in.dto';
import { SignUpDto } from './dtos/sign-up.dto';
Expand All @@ -8,6 +15,10 @@ import { VerifyUserDto } from './dtos/verify-user.dto';
import { DeleteUserDto } from './dtos/delete-user.dto';
import { User } from '../users/user.entity';
import { SignInResponseDto } from './dtos/sign-in-response.dto';
import { RefreshTokenDto } from './dtos/refresh-token.dto';
import { AuthGuard } from '@nestjs/passport';
import { ConfirmPasswordDto } from './dtos/confirm-password.dto';
import { ForgotPasswordDto } from './dtos/forgot-password.dto';

@Controller('auth')
export class AuthController {
Expand All @@ -18,6 +29,7 @@ export class AuthController {

@Post('/signup')
async createUser(@Body() signUpDto: SignUpDto): Promise<User> {
// By default, creates a standard user
try {
await this.authService.signup(signUpDto);
} catch (e) {
Expand Down Expand Up @@ -48,8 +60,24 @@ export class AuthController {
return this.authService.signin(signInDto);
}

// TODO implement change/forgotPassword endpoint
// https://dev.to/fstbraz/authentication-with-aws-cognito-passport-and-nestjs-part-iii-2da5
@UseGuards(AuthGuard('jwt'))
@Post('/refresh')
refresh(
@Body() refreshDto: RefreshTokenDto,
@Request() request,
): Promise<SignInResponseDto> {
return this.authService.refreshToken(refreshDto, request.user.idUser);
}

@Post('/forgotPassword')
forgotPassword(@Body() body: ForgotPasswordDto): Promise<void> {
return this.authService.forgotPassword(body.email);
}

@Post('/confirmPassword')
confirmPassword(@Body() body: ConfirmPasswordDto): Promise<void> {
return this.authService.confirmForgotPassword(body);
}

@Post('/delete')
async delete(@Body() body: DeleteUserDto): Promise<void> {
Expand Down
212 changes: 110 additions & 102 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,50 @@
import { Injectable } from '@nestjs/common';
import {
AuthenticationDetails,
CognitoUser,
CognitoUserAttribute,
CognitoUserPool,
ISignUpResult,
} from 'amazon-cognito-identity-js';
import {
AdminDeleteUserCommand,
AttributeType,
CognitoIdentityProviderClient,
ConfirmForgotPasswordCommand,
ConfirmSignUpCommand,
ForgotPasswordCommand,
InitiateAuthCommand,
ListUsersCommand,
SignUpCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import CognitoAuthConfig from './aws-exports';
import { SignUpDto } from './dtos/sign-up.dto';
import { SignInDto } from './dtos/sign-in.dto';
import { SignInResponseDto } from './dtos/sign-in-response.dto';
import { createHmac } from 'crypto';
import { RefreshTokenDto } from './dtos/refresh-token.dto';
import { Status } from '../users/types';
import { ConfirmPasswordDto } from './dtos/confirm-password.dto';

@Injectable()
export class AuthService {
private readonly userPool: CognitoUserPool;
private readonly providerClient: CognitoIdentityProviderClient;
private readonly clientSecret: string;

constructor() {
this.userPool = new CognitoUserPool({
UserPoolId: CognitoAuthConfig.userPoolId,
ClientId: CognitoAuthConfig.clientId,
});

this.providerClient = new CognitoIdentityProviderClient({
region: CognitoAuthConfig.region,
credentials: {
accessKeyId: process.env.NX_AWS_ACCESS_KEY,
secretAccessKey: process.env.NX_AWS_SECRET_ACCESS_KEY,
},
});

this.clientSecret = process.env.COGNITO_CLIENT_SECRET;
}

// Computes secret hash to authenticate this backend to Cognito
// Hash key is the Cognito client secret, message is username + client ID
// Username value depends on the command
// (see https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash)
calculateHash(username: string): string {
const hmac = createHmac('sha256', this.clientSecret);
hmac.update(username + CognitoAuthConfig.clientId);
return hmac.digest('base64');
}

async getUser(userSub: string): Promise<AttributeType[]> {
Expand All @@ -49,113 +58,112 @@ export class AuthService {
return Users[0].Attributes;
}

signup({
firstName,
lastName,
email,
password,
}: SignUpDto): Promise<ISignUpResult> {
return new Promise((resolve, reject) => {
return this.userPool.signUp(
email,
password,
[
new CognitoUserAttribute({
Name: 'name',
Value: `${firstName} ${lastName}`,
}),
],
null,
(err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
async signup(
{ firstName, lastName, email, password }: SignUpDto,
status: Status = Status.STANDARD,
): Promise<boolean> {
// Needs error handling
const signUpCommand = new SignUpCommand({
ClientId: CognitoAuthConfig.clientId,
SecretHash: this.calculateHash(email),
Username: email,
Password: password,
UserAttributes: [
{
Name: 'name',
Value: `${firstName} ${lastName}`,
},
// Optional: add a custom Cognito attribute called "role" that also stores the user's status/role
// If you choose to do so, you'll have to first add this custom attribute in your user pool
{
Name: 'custom:role',
Value: status,
},
);
],
});

const response = await this.providerClient.send(signUpCommand);
return response.UserConfirmed;
}

verifyUser(email: string, verificationCode: string): Promise<unknown> {
return new Promise((resolve, reject) => {
return new CognitoUser({
Username: email,
Pool: this.userPool,
}).confirmRegistration(verificationCode, true, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
async verifyUser(email: string, verificationCode: string): Promise<void> {
const confirmCommand = new ConfirmSignUpCommand({
ClientId: CognitoAuthConfig.clientId,
SecretHash: this.calculateHash(email),
Username: email,
ConfirmationCode: verificationCode,
});

await this.providerClient.send(confirmCommand);
}

signin({ email, password }: SignInDto): Promise<SignInResponseDto> {
const authenticationDetails = new AuthenticationDetails({
Username: email,
Password: password,
async signin({ email, password }: SignInDto): Promise<SignInResponseDto> {
const signInCommand = new InitiateAuthCommand({
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: CognitoAuthConfig.clientId,
AuthParameters: {
USERNAME: email,
PASSWORD: password,
SECRET_HASH: this.calculateHash(email),
},
});

const userData = {
Username: email,
Pool: this.userPool,
};
const response = await this.providerClient.send(signInCommand);

const cognitoUser = new CognitoUser(userData);
return {
accessToken: response.AuthenticationResult.AccessToken,
refreshToken: response.AuthenticationResult.RefreshToken,
idToken: response.AuthenticationResult.IdToken,
};
}

return new Promise<SignInResponseDto>((resolve, reject) => {
return cognitoUser.authenticateUser(authenticationDetails, {
onSuccess: (result) => {
resolve({
accessToken: result.getAccessToken().getJwtToken(),
refreshToken: result.getRefreshToken().getToken(),
});
},
onFailure: (err) => {
reject(err);
},
});
// Refresh token hash uses a user's sub (unique ID), not their username (typically their email)
async refreshToken(
{ refreshToken }: RefreshTokenDto,
userSub: string,
): Promise<SignInResponseDto> {
const refreshCommand = new InitiateAuthCommand({
AuthFlow: 'REFRESH_TOKEN_AUTH',
ClientId: CognitoAuthConfig.clientId,
AuthParameters: {
REFRESH_TOKEN: refreshToken,
SECRET_HASH: this.calculateHash(userSub),
},
});

const response = await this.providerClient.send(refreshCommand);

return {
accessToken: response.AuthenticationResult.AccessToken,
refreshToken: refreshToken,
idToken: response.AuthenticationResult.IdToken,
};
}

// TODO not currently used
forgotPassword(email: string): Promise<unknown> {
return new Promise((resolve, reject) => {
return new CognitoUser({
Username: email,
Pool: this.userPool,
}).forgotPassword({
onSuccess: function (result) {
resolve(result);
},
onFailure: function (err) {
reject(err);
},
});
async forgotPassword(email: string) {
const forgotCommand = new ForgotPasswordCommand({
ClientId: CognitoAuthConfig.clientId,
Username: email,
SecretHash: this.calculateHash(email),
});

await this.providerClient.send(forgotCommand);
}

// TODO not currently used
confirmPassword(
email: string,
verificationCode: string,
newPassword: string,
): Promise<unknown> {
return new Promise((resolve, reject) => {
return new CognitoUser({
Username: email,
Pool: this.userPool,
}).confirmPassword(verificationCode, newPassword, {
onSuccess: function (result) {
resolve(result);
},
onFailure: function (err) {
reject(err);
},
});
async confirmForgotPassword({
email,
confirmationCode,
newPassword,
}: ConfirmPasswordDto) {
const confirmComamnd = new ConfirmForgotPasswordCommand({
ClientId: CognitoAuthConfig.clientId,
SecretHash: this.calculateHash(email),
Username: email,
ConfirmationCode: confirmationCode,
Password: newPassword,
});

await this.providerClient.send(confirmComamnd);
}

async deleteUser(email: string): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/auth/aws-exports.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const CognitoAuthConfig = {
userPoolId: 'us-east-2_hTtZ5N5ZV',
clientId: '4c5b8m6tno9fvljmseqgmk82fv',
userPoolId: 'USER POOL ID HERE',
clientId: 'CLIENT ID HERE',
region: 'us-east-2',
};

Expand Down
12 changes: 12 additions & 0 deletions apps/backend/src/auth/dtos/confirm-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsEmail, IsString } from 'class-validator';

export class ConfirmPasswordDto {
@IsEmail()
email: string;

@IsString()
newPassword: string;

@IsString()
confirmationCode: string;
}
6 changes: 6 additions & 0 deletions apps/backend/src/auth/dtos/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';

export class ForgotPasswordDto {
@IsEmail()
email: string;
}
6 changes: 6 additions & 0 deletions apps/backend/src/auth/dtos/refresh-token.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';

export class RefreshTokenDto {
@IsString()
refreshToken: string;
}
2 changes: 2 additions & 0 deletions apps/backend/src/auth/dtos/sign-in-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export class SignInResponseDto {
accessToken: string;

refreshToken: string;

idToken: string;
}
9 changes: 7 additions & 2 deletions apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import { Status } from './types';
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {}

async create(email: string, firstName: string, lastName: string) {
async create(
email: string,
firstName: string,
lastName: string,
status: Status = Status.STANDARD,
) {
const userId = (await this.repo.count()) + 1;
const user = this.repo.create({
id: userId,
status: Status.STANDARD,
status,
firstName,
lastName,
email,
Expand Down

0 comments on commit e17079c

Please sign in to comment.