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

Feat/register users #15

Merged
merged 11 commits into from
Sep 24, 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
6 changes: 3 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,15 @@ jobs:
DB_USERNAME=${{ secrets.DB_USERNAME }}
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }}
ACCESS_TOKEN_EXPIRES_IN=${{ vars.ACCESS_TOKEN_EXPIRES_IN }}
RESET_PASSWORD_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }}
ACCESS_TOKEN_EXPIRES_IN=${{ secrets.ACCESS_TOKEN_EXPIRES_IN }}
RESET_PASSWORD_TOKEN_SECRET=${{ secrets.RESET_PASSWORD_TOKEN_SECRET }}
RESET_PASSWORD_TOKEN_EXPIRES_IN=${{ secrets.RESET_PASSWORD_TOKEN_EXPIRES_IN }}
EMAIL_CONFIRMATION_TOKEN_SECRET=${{ secrets.EMAIL_CONFIRMATION_TOKEN_SECRET }}
EMAIL_CONFIRMATION_TOKEN_EXPIRES_IN=${{ secrets.EMAIL_CONFIRMATION_TOKEN_EXPIRES_IN }}
AWS_SES_ACCESS_KEY_ID=${{ secrets.AWS_SES_ACCESS_KEY_ID }}
AWS_SES_ACCESS_KEY_SECRET=${{ secrets.AWS_SES_ACCESS_KEY_SECRET }}
AWS_SES_DOMAIN=${{ secrets.AWS_SES_DOMAIN }}
AWS_SES_REGION=${{ secrets.AWS_SES_REGION }}
AWS_SES_REGION=${{ secrets.AWS_REGION }}
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
Expand Down
2 changes: 1 addition & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ENV DB_NAME $DB_NAME
ENV DB_USERNAME $DB_USERNAME
ENV DB_PASSWORD $DB_PASSWORD
ENV ACCESS_TOKEN_SECRET $ACCESS_TOKEN_SECRET
ENV ACCESS_TOKEN_SECRET $ACCESS_TOKEN_EXPIRES_IN
ENV ACCESS_TOKEN_EXPIRES_IN $ACCESS_TOKEN_EXPIRES_IN
ENV RESET_PASSWORD_TOKEN_SECRET $RESET_PASSWORD_TOKEN_SECRET
ENV RESET_PASSWORD_TOKEN_EXPIRES_IN $RESET_PASSWORD_TOKEN_EXPIRES_IN
ENV EMAIL_CONFIRMATION_TOKEN_SECRET $EMAIL_CONFIRMATION_TOKEN_SECRET
Expand Down
9 changes: 8 additions & 1 deletion api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { ApiConfigModule } from '@api/modules/config/app-config.module';
import { AuthModule } from '@api/modules/auth/auth.module';
import { NotificationsModule } from '@api/modules/notifications/notifications.module';
import { EventsModule } from '@api/modules/events/events.module';
import { AdminModule } from '@api/modules/admin/admin.module';

@Module({
imports: [ApiConfigModule, AuthModule, NotificationsModule, EventsModule],
imports: [
ApiConfigModule,
AuthModule,
NotificationsModule,
EventsModule,
AdminModule,
],
controllers: [AppController],
providers: [AppService],
})
Expand Down
27 changes: 27 additions & 0 deletions api/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Controller, UseGuards } from '@nestjs/common';
import { RolesGuard } from '@api/modules/auth/guards/roles.guard';
import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service';
import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard';
import { ROLES } from '@api/modules/auth/authorisation/roles.enum';
import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator';
import { tsRestHandler, TsRestHandler } from '@ts-rest/nest';
import { ControllerResponse } from '@api/types/controller-response.type';
import { adminContract } from '@shared/contracts/admin.contract';

@Controller()
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
constructor(private readonly auth: AuthenticationService) {}

@RequiredRoles(ROLES.ADMIN)
@TsRestHandler(adminContract.createUser)
async createUser(): Promise<ControllerResponse> {
return tsRestHandler(adminContract.createUser, async ({ body }) => {
await this.auth.createUser(body);
return {
status: 201,
body: null,
};
});
}
}
9 changes: 9 additions & 0 deletions api/src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AuthModule } from '@api/modules/auth/auth.module';

@Module({
imports: [AuthModule],
controllers: [AdminController],
})
export class AdminModule {}
1 change: 1 addition & 0 deletions api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ import { AuthenticationController } from '@api/modules/auth/authentication/authe
imports: [AuthenticationModule, AuthorisationModule, NotificationsModule],
controllers: [AuthenticationController],
providers: [PasswordRecoveryService, AuthMailer],
exports: [AuthenticationModule, AuthMailer],
})
export class AuthModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ 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 { authContract } from '@shared/contracts/auth/auth.contract';

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';

@Controller()
@UseInterceptors(ClassSerializerInterceptor)
Expand Down
47 changes: 36 additions & 11 deletions api/src/modules/auth/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { JwtService } from '@nestjs/jwt';
import { UsersService } from '@api/modules/users/users.service';
import { User } from '@shared/entities/users/user.entity';
import * as bcrypt from 'bcrypt';
import { LoginDto } from '@api/modules/auth/dtos/login.dto';
import { JwtPayload } from '@api/modules/auth/strategies/jwt.strategy';
import { EventBus } from '@nestjs/cqrs';
import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event';
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 { ApiConfigService } from '@api/modules/config/app-config.service';
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';

@Injectable()
export class AuthenticationService {
Expand All @@ -18,6 +18,7 @@ export class AuthenticationService {
private readonly jwt: JwtService,
private readonly apiConfig: ApiConfigService,
private readonly eventBus: EventBus,
private readonly commandBus: CommandBus,
) {}
async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
Expand All @@ -27,19 +28,28 @@ export class AuthenticationService {
throw new UnauthorizedException(`Invalid credentials`);
}

// TODO: Move logic to createUser service
async signUp(signupDto: LoginDto): Promise<void> {
const passwordHash = await bcrypt.hash(signupDto.password, 10);
async createUser(createUser: CreateUserDto): Promise<void> {
// TODO: This is sync, check how to improve it
const { email, name, partnerName } = createUser;
const plainTextPassword = randomBytes(8).toString('hex');
const passwordHash = await bcrypt.hash(plainTextPassword, 10);
const newUser = await this.usersService.createUser({
email: signupDto.email,
name,
email,
password: passwordHash,
partnerName,
isActive: false,
});
this.eventBus.publish(new UserSignedUpEvent(newUser.id, newUser.email));
await this.commandBus
.execute(new SendWelcomeEmailCommand(newUser, plainTextPassword))
.catch(() => this.usersService.delete(newUser));
}

async logIn(user: User): Promise<UserWithAccessToken> {
const payload: JwtPayload = { id: user.id };
const accessToken: string = this.jwt.sign(payload);
const { token: accessToken } = await this.signTokenByType(
TOKEN_TYPE_ENUM.ACCESS,
user.id,
);
return { user, accessToken };
}

Expand All @@ -52,4 +62,19 @@ export class AuthenticationService {
throw new UnauthorizedException();
}
}

private async signTokenByType(
tokenType: TOKEN_TYPE_ENUM,
userId: string,
): Promise<{ token: string; expiresIn: string }> {
const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(tokenType);
const token = await this.jwt.signAsync(
{ id: userId },
{
secret,
expiresIn,
},
);
return { token, expiresIn };
}
}
6 changes: 0 additions & 6 deletions api/src/modules/auth/authorisation/roles.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,3 @@ export enum ROLES {
PARTNER = 'partner',
GENERAL_USER = 'general_user',
}

export const ROLES_HIERARCHY = {
[ROLES.ADMIN]: [ROLES.PARTNER, ROLES.GENERAL_USER],
[ROLES.PARTNER]: [ROLES.GENERAL_USER],
[ROLES.GENERAL_USER]: [],
};
11 changes: 2 additions & 9 deletions api/src/modules/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
ROLES,
ROLES_HIERARCHY,
} from '@api/modules/auth/authorisation/roles.enum';
import { ROLES } from '@api/modules/auth/authorisation/roles.enum';
import { ROLES_KEY } from '@api/modules/auth/decorators/roles.decorator';

@Injectable()
Expand All @@ -24,10 +21,6 @@ export class RolesGuard implements CanActivate {
}

private hasRequiredRole(userRole: ROLES, requiredRoles: ROLES[]): boolean {
return requiredRoles.some(
(requiredRole) =>
userRole === requiredRole ||
ROLES_HIERARCHY[userRole]?.includes(requiredRole),
);
return requiredRoles.some((role) => userRole === role);
}
}
68 changes: 57 additions & 11 deletions api/src/modules/auth/services/auth.mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {
} from '@api/modules/notifications/email/email-service.interface';
import { ApiConfigService } from '@api/modules/config/app-config.service';
import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema';
import { JwtService } from '@nestjs/jwt';
import { User } from '@shared/entities/users/user.entity';

export type PasswordRecovery = {
email: string;
token: string;
export type PasswordRecoveryDto = {
user: User;
origin: string;
};

Expand All @@ -18,19 +19,17 @@ export class AuthMailer {
@Inject(IEmailServiceToken)
private readonly emailService: IEmailServiceInterface,
private readonly apiConfig: ApiConfigService,
private readonly jwt: JwtService,
) {}

async sendPasswordRecoveryEmail(
passwordRecovery: PasswordRecovery,
passwordRecovery: PasswordRecoveryDto,
): Promise<void> {
// TODO: Investigate if it's worth using a template engine to generate the email content, the mail service provider allows it
// TODO: Use a different expiration time, or different secret altogether for password recovery

const { expiresIn } = this.apiConfig.getJWTConfigByType(
const { token, expiresIn } = await this.signTokenByType(
TOKEN_TYPE_ENUM.RESET_PASSWORD,
passwordRecovery.user.id,
);

const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${passwordRecovery.token}`;
const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${token}`;

const htmlContent: string = `
<h1>Dear User,</h1>
Expand All @@ -47,11 +46,58 @@ export class AuthMailer {

await this.emailService.sendMail({
from: 'password-recovery',
to: passwordRecovery.email,
to: passwordRecovery.user.email,
subject: 'Recover Password',
html: htmlContent,
});
}

async sendWelcomeEmail(welcomeEmailDto: {
user: User;
defaultPassword: string;
}) {
const { token, expiresIn } = await this.signTokenByType(
TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION,
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/${token}`;

const htmlContent: string = `
<h1>Dear User,</h1>
<br/>
<p>Welcome to the TNC Blue Carbon Cost Tool Platform</p>
<br/>
<p>Thank you for signing up. We're excited to have you on board. Please active you account by signing up adding a password of your choice</p>
<p><a href="${resetPasswordUrl}" target="_blank" rel="noopener noreferrer">Sign Up Link</a></p>
<br/>
<p>Your one-time password is ${welcomeEmailDto.defaultPassword}</p>
<p>For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.</p>
<br/>
<p>Thank you for using the platform. We're committed to ensuring your account's security.</p>
<p>Best regards.</p>`;

await this.emailService.sendMail({
from: 'welcome',
to: welcomeEmailDto.user.email,
subject: 'Welcome to TNC Blue Carbon Cost Tool Platform',
html: htmlContent,
});
}

private async signTokenByType(
tokenType: TOKEN_TYPE_ENUM,
userId: string,
): Promise<{ token: string; expiresIn: string }> {
const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(tokenType);
const token = await this.jwt.signAsync(
{ id: userId },
{ secret, expiresIn },
);
return { token, expiresIn };
}
}

const passwordRecoveryTokenExpirationHumanReadable = (
Expand Down
9 changes: 1 addition & 8 deletions api/src/modules/auth/services/password-recovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ export class PasswordRecoveryService {
logger: Logger = new Logger(PasswordRecoveryService.name);
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
private readonly authMailer: AuthMailer,
private readonly eventBus: EventBus,
private readonly apiConfig: ApiConfigService,
) {}

async requestPasswordRecovery(email: string, origin: string): Promise<void> {
Expand All @@ -29,13 +27,8 @@ export class PasswordRecoveryService {
this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null));
return;
}
const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(
TOKEN_TYPE_ENUM.RESET_PASSWORD,
);
const token = this.jwt.sign({ id: user.id }, { secret, expiresIn });
await this.authMailer.sendPasswordRecoveryEmail({
email: user.email,
token,
user,
origin,
});
this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { User } from '@shared/entities/users/user.entity';

export class SendWelcomeEmailCommand {
constructor(
public readonly user: User,
public readonly plainPassword: string,
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// send-welcome-email.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { SendWelcomeEmailCommand } from './send-welcome-email.command';
import { AuthMailer } from '@api/modules/auth/services/auth.mailer';

@CommandHandler(SendWelcomeEmailCommand)
export class SendWelcomeEmailHandler
implements ICommandHandler<SendWelcomeEmailCommand>
{
constructor(private readonly authMailer: AuthMailer) {}

async execute(command: SendWelcomeEmailCommand): Promise<void> {
const { user, plainPassword } = command;
await this.authMailer.sendWelcomeEmail({
user,
defaultPassword: plainPassword,
});
}
}
6 changes: 5 additions & 1 deletion api/src/modules/notifications/email/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface';
import { NodemailerEmailService } from '@api/modules/notifications/email/nodemailer.email.service';
import { SendWelcomeEmailHandler } from '@api/modules/notifications/email/commands/send-welcome-email.handler';
import { AuthModule } from '@api/modules/auth/auth.module';

@Module({
imports: [forwardRef(() => AuthModule)],
providers: [
{ provide: IEmailServiceToken, useClass: NodemailerEmailService },
SendWelcomeEmailHandler,
],
exports: [IEmailServiceToken],
})
Expand Down
Loading
Loading