Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tbcct 44 endpoint to sign up #18

Merged
merged 4 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service';
import { AuthMailer } from '@api/modules/auth/services/auth.mailer';
import { NotificationsModule } from '@api/modules/notifications/notifications.module';
import { AuthenticationController } from '@api/modules/auth/authentication.controller';
import { AuthenticationModule } from '@api/modules/auth/authentication.module';
import { RequestPasswordRecoveryHandler } from '@api/modules/auth/commands/request-password-recovery.handler';

@Module({
imports: [AuthenticationModule, NotificationsModule],
controllers: [AuthenticationController],
providers: [PasswordRecoveryService, AuthMailer],
providers: [AuthMailer, RequestPasswordRecoveryHandler],
exports: [AuthenticationModule, AuthMailer],
})
export class AuthModule {}
30 changes: 21 additions & 9 deletions api/src/modules/auth/authentication.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,27 @@ import {
UseInterceptors,
ClassSerializerInterceptor,
HttpStatus,
UnauthorizedException,
} from '@nestjs/common';
import { User } from '@shared/entities/users/user.entity';
import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard';
import { GetUser } from '@api/modules/auth/decorators/get-user.decorator';
import { Public } from '@api/modules/auth/decorators/is-public.decorator';
import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service';
import { tsRestHandler, TsRestHandler } from '@ts-rest/nest';
import { ControllerResponse } from '@api/types/controller-response.type';
import { AuthGuard } from '@nestjs/passport';
import { ResetPassword } from '@api/modules/auth/strategies/reset-password.strategy';
import { authContract } from '@shared/contracts/auth.contract';
import { AuthenticationService } from '@api/modules/auth/authentication.service';
import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy';
import { CommandBus } from '@nestjs/cqrs';
import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command';

@Controller()
@UseInterceptors(ClassSerializerInterceptor)
export class AuthenticationController {
constructor(
private authService: AuthenticationService,
private readonly passwordRecovery: PasswordRecoveryService,
private readonly commandBus: CommandBus,
) {}

@Public()
Expand All @@ -40,18 +41,27 @@ export class AuthenticationController {
});
}

@UseGuards(AuthGuard(SignUp))
@TsRestHandler(authContract.signUp)
async signUp(@GetUser() user: User): Promise<ControllerResponse> {
return tsRestHandler(authContract.signUp, async ({ body }) => {
await this.authService.signUp(user, body);
return {
body: null,
status: 201,
};
});
}

@UseGuards(AuthGuard(ResetPassword))
@TsRestHandler(authContract.resetPassword)
async resetPassword(@GetUser() user: User): Promise<ControllerResponse> {
return tsRestHandler(
authContract.resetPassword,
async ({ body: { password } }) => {
const userWithAccessToken = await this.passwordRecovery.resetPassword(
user,
password,
);
await this.authService.updatePassword(user, password);
return {
body: userWithAccessToken,
body: null,
status: 201,
};
},
Expand All @@ -65,7 +75,9 @@ export class AuthenticationController {
return tsRestHandler(
authContract.requestPasswordRecovery,
async ({ body: { email } }) => {
await this.passwordRecovery.requestPasswordRecovery(email, origin);
await this.commandBus.execute(
new RequestPasswordRecoveryCommand(email),
);
return {
body: null,
status: HttpStatus.CREATED,
Expand Down
8 changes: 8 additions & 0 deletions api/src/modules/auth/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { JwtStrategy } from '@api/modules/auth/strategies/jwt.strategy';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';
import { ResetPasswordJwtStrategy } from '@api/modules/auth/strategies/reset-password.strategy';
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
import { SignUpStrategy } from '@api/modules/auth/strategies/sign-up.strategy';

@Module({
imports: [
Expand Down Expand Up @@ -46,6 +47,13 @@ import { JwtManager } from '@api/modules/auth/services/jwt.manager';
},
inject: [UsersService, ApiConfigService],
},
{
provide: SignUpStrategy,
useFactory: (users: UsersService, config: ApiConfigService) => {
return new SignUpStrategy(users, config);
},
inject: [UsersService, ApiConfigService],
},
],
exports: [UsersModule, AuthenticationService, JwtManager],
})
Expand Down
19 changes: 18 additions & 1 deletion api/src/modules/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '@api/modules/users/users.service';
import { User } from '@shared/entities/users/user.entity';
import * as bcrypt from 'bcrypt';
import { CommandBus } from '@nestjs/cqrs';
import { CommandBus, EventBus } from '@nestjs/cqrs';
import { UserWithAccessToken } from '@shared/dtos/user.dto';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';
import { CreateUserDto } from '@shared/schemas/users/create-user.schema';
import { randomBytes } from 'node:crypto';
import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command';
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
import { SignUpDto } from '@shared/schemas/auth/sign-up.schema';
import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event';

@Injectable()
export class AuthenticationService {
constructor(
private readonly usersService: UsersService,
private readonly jwtManager: JwtManager,
private readonly commandBus: CommandBus,
private readonly eventBus: EventBus,
) {}
async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
Expand Down Expand Up @@ -47,10 +50,24 @@ export class AuthenticationService {
return { user, accessToken };
}

async signUp(user: User, signUpDto: SignUpDto): Promise<void> {
const { password, newPassword } = signUpDto;
if (!(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException();
}
user.isActive = true;
await this.usersService.updatePassword(user, newPassword);
this.eventBus.publish(new UserSignedUpEvent(user.id, user.email));
}

async verifyToken(token: string, type: TOKEN_TYPE_ENUM): Promise<boolean> {
if (await this.jwtManager.isTokenValid(token, type)) {
return true;
}
throw new UnauthorizedException();
}

async updatePassword(user: User, newPassword: string): Promise<void> {
await this.usersService.updatePassword(user, newPassword);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class RequestPasswordRecoveryCommand {
constructor(public readonly email: string) {}
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,32 @@
import { Injectable, Logger } from '@nestjs/common';
import { UsersService } from '@api/modules/users/users.service';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
import { AuthMailer } from '@api/modules/auth/services/auth.mailer';
import { EventBus } from '@nestjs/cqrs';
import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command';
import { UsersService } from '@api/modules/users/users.service';
import { PasswordRecoveryRequestedEvent } from '@api/modules/events/user-events/password-recovery-requested.event';
import { User } from '@shared/entities/users/user.entity';
import * as bcrypt from 'bcrypt';
import { NotFoundException } from '@nestjs/common';

@Injectable()
export class PasswordRecoveryService {
logger: Logger = new Logger(PasswordRecoveryService.name);
@CommandHandler(RequestPasswordRecoveryCommand)
export class RequestPasswordRecoveryHandler
implements ICommandHandler<RequestPasswordRecoveryCommand>
{
constructor(
private readonly users: UsersService,
private readonly authMailer: AuthMailer,
private readonly eventBus: EventBus,
) {}

async requestPasswordRecovery(email: string, origin: string): Promise<void> {
async execute(command: RequestPasswordRecoveryCommand): Promise<void> {
const { email } = command;
const user = await this.users.findByEmail(email);
if (!user) {
this.logger.warn(
`Email ${email} not found when trying to recover password`,
);
this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null));
return;
throw new NotFoundException(`Email ${email} not found`);
}
await this.authMailer.sendPasswordRecoveryEmail({
user,
origin,
// TODO: Origin must come from env vars
origin: 'http://localhost:3000',
});
this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id));
}

async resetPassword(user: User, newPassword: string): Promise<void> {
const newHashedPassword = await bcrypt.hash(newPassword, 10);
await this.users.updatePassword(user, newHashedPassword);
}
}
7 changes: 4 additions & 3 deletions api/src/modules/auth/services/auth.mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ export class AuthMailer {
user: User;
defaultPassword: string;
}) {
const { emailConfirmationToken, expiresIn } =
await this.jwt.signEmailConfirmationToken(welcomeEmailDto.user.id);
const { signUpToken, expiresIn } = await this.jwt.signSignUpToken(
welcomeEmailDto.user.id,
);

// TODO: We need to know the URL to confirm the email, we could rely on origin but we would need to pass it through a lot of code.
// probably better to have a config value for this.
const resetPasswordUrl = `TODO/auth/sign-up/${emailConfirmationToken}`;
const resetPasswordUrl = `TODO/auth/sign-up/${signUpToken}`;

const htmlContent: string = `
<h1>Dear User,</h1>
Expand Down
12 changes: 6 additions & 6 deletions api/src/modules/auth/services/jwt.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ export class JwtManager {
};
}

async signEmailConfirmationToken(
async signSignUpToken(
userId: string,
): Promise<{ emailConfirmationToken: string; expiresIn: string }> {
const { token: emailConfirmationToken, expiresIn } = await this.sign(
): Promise<{ signUpToken: string; expiresIn: string }> {
const { token: signUpToken, expiresIn } = await this.sign(
userId,
TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION,
TOKEN_TYPE_ENUM.SIGN_UP,
);
return {
emailConfirmationToken,
signUpToken: signUpToken,
expiresIn,
};
}
Expand All @@ -71,7 +71,7 @@ export class JwtManager {
try {
const { id } = await this.jwt.verifyAsync(token, { secret });
switch (type) {
case TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION:
case TOKEN_TYPE_ENUM.SIGN_UP:
/**
* If the user is already active, we don't want to allow them to confirm their email again.
*/
Expand Down
2 changes: 1 addition & 1 deletion api/src/modules/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: JwtPayload) {
const { id } = payload;
const user = await this.userService.findOneBy(id);
if (!user) {
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
return user;
Expand Down
33 changes: 33 additions & 0 deletions api/src/modules/auth/strategies/sign-up.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '@api/modules/users/users.service';
import { ApiConfigService } from '@api/modules/config/app-config.service';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';

export type JwtPayload = { id: string };

export const SignUp = 'sign-up';

@Injectable()
export class SignUpStrategy extends PassportStrategy(Strategy, SignUp) {
constructor(
private readonly userService: UsersService,
private readonly config: ApiConfigService,
) {
const { secret } = config.getJWTConfigByType(TOKEN_TYPE_ENUM.SIGN_UP);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: secret,
});
}

async validate(payload: JwtPayload) {
const { id } = payload;
const user = await this.userService.findOneBy(id);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
2 changes: 1 addition & 1 deletion api/src/modules/config/auth-config.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class JwtConfigHandler {
),
};

case TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION:
case TOKEN_TYPE_ENUM.SIGN_UP:
return {
secret: this.configService.get<string>(
'EMAIL_CONFIRMATION_TOKEN_SECRET',
Expand Down
2 changes: 1 addition & 1 deletion api/src/modules/events/events.enum.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export enum API_EVENT_TYPES {
USER_SIGNED_UP = 'user.signed_up',
USER_CREATED = 'user.created',
USER_PASSWORD_RECOVERY_REQUESTED = 'user.password_recovery_requested',
USER_PASSWORD_RECOVERY_REQUESTED_NON_EXISTENT = 'user.password_recovery_requested_non_existent',

EMAIL_FAILED = 'system.email.failed',
// More events to come....
}
Expand Down
3 changes: 2 additions & 1 deletion api/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '@shared/entities/users/user.entity';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
Expand All @@ -26,7 +27,7 @@ export class UsersService {
}

async updatePassword(user: User, newPassword: string) {
user.password = newPassword;
user.password = await bcrypt.hash(newPassword, 10);
return this.repo.save(user);
}

Expand Down
2 changes: 1 addition & 1 deletion api/test/e2e/features/password-recovery-send-email.feature
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ Feature: Password Recovery

Scenario: No email should be sent if the user is not found
When the user requests password recovery with an invalid email
Then the user should receive a 201 status code
Then the user should receive a 404 status code
And no email should be sent
3 changes: 3 additions & 0 deletions api/test/e2e/steps/password-recovery-reset-email.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ describe('Reset Password', () => {
resetPasswordSecret = apiConfigService.getJWTConfigByType(
TOKEN_TYPE_ENUM.RESET_PASSWORD,
).secret;
});

afterEach(async () => {
await testManager.clearDatabase();
});

Expand Down
9 changes: 5 additions & 4 deletions api/test/integration/auth/create-user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ describe('Create Users', () => {
// Given a user exists with valid credentials
// But the user has the role partner

const user = await testManager
.mocks()
.createUser({ role: ROLES.PARTNER, email: '[email protected]' });
const user = await testManager.mocks().createUser({
role: ROLES.PARTNER,
email: '[email protected]',
});
const { jwtToken } = await testManager.logUserIn(user);

// When the user creates a new user
Expand Down Expand Up @@ -72,7 +73,7 @@ describe('Create Users', () => {
);
});

test('An Admin registers a new user', async () => {
test('An Admin registers a new user ', async () => {
// Given a admin user exists with valid credentials
// beforeAll
const newUser = {
Expand Down
Loading
Loading