diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index 9e67eaaf..7ede1c78 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -5,7 +5,6 @@ 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'; @@ -18,6 +17,8 @@ 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 { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; +import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy'; @Controller() @UseInterceptors(ClassSerializerInterceptor) @@ -40,6 +41,18 @@ export class AuthenticationController { }); } + @UseGuards(JwtAuthGuard, AuthGuard(SignUp)) + @TsRestHandler(authContract.signUp) + async signUp(@GetUser() user: User): Promise { + return tsRestHandler(authContract.login, 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 { diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index df603b8a..4073dabc 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -2,13 +2,15 @@ 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 { @@ -16,6 +18,7 @@ export class AuthenticationService { private readonly usersService: UsersService, private readonly jwtManager: JwtManager, private readonly commandBus: CommandBus, + private readonly eventBus: EventBus, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -47,6 +50,16 @@ export class AuthenticationService { return { user, accessToken }; } + async signUp(user: User, signUpDto: SignUpDto): Promise { + 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 { if (await this.jwtManager.isTokenValid(token, type)) { return true; diff --git a/api/src/modules/auth/services/jwt.manager.ts b/api/src/modules/auth/services/jwt.manager.ts index e06da730..a49b7a1e 100644 --- a/api/src/modules/auth/services/jwt.manager.ts +++ b/api/src/modules/auth/services/jwt.manager.ts @@ -58,7 +58,7 @@ export class JwtManager { ): Promise<{ emailConfirmationToken: string; expiresIn: string }> { const { token: emailConfirmationToken, expiresIn } = await this.sign( userId, - TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + TOKEN_TYPE_ENUM.SIGN_UP, ); return { emailConfirmationToken, @@ -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. */ diff --git a/api/src/modules/auth/strategies/sign-up.strategy.ts b/api/src/modules/auth/strategies/sign-up.strategy.ts new file mode 100644 index 00000000..b3d81331 --- /dev/null +++ b/api/src/modules/auth/strategies/sign-up.strategy.ts @@ -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; + } +} diff --git a/api/src/modules/config/auth-config.handler.ts b/api/src/modules/config/auth-config.handler.ts index 8f8b6865..c48e22ed 100644 --- a/api/src/modules/config/auth-config.handler.ts +++ b/api/src/modules/config/auth-config.handler.ts @@ -25,7 +25,7 @@ export class JwtConfigHandler { ), }; - case TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION: + case TOKEN_TYPE_ENUM.SIGN_UP: return { secret: this.configService.get( 'EMAIL_CONFIRMATION_TOKEN_SECRET', diff --git a/api/src/modules/events/events.enum.ts b/api/src/modules/events/events.enum.ts index 60c25cb0..22b87e9c 100644 --- a/api/src/modules/events/events.enum.ts +++ b/api/src/modules/events/events.enum.ts @@ -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.... } diff --git a/api/test/integration/auth/sign-up.spec.ts b/api/test/integration/auth/sign-up.spec.ts index 0a5a5cec..5f809d9c 100644 --- a/api/test/integration/auth/sign-up.spec.ts +++ b/api/test/integration/auth/sign-up.spec.ts @@ -37,7 +37,7 @@ describe('Create Users', () => { isActive: true, }); const { secret, expiresIn } = apiConfig.getJWTConfigByType( - TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + TOKEN_TYPE_ENUM.SIGN_UP, ); const token = jwtService.sign({ id: user.id }, { secret, expiresIn }); @@ -48,7 +48,7 @@ describe('Create Users', () => { .request() .get(authContract.validateToken.path) .set('Authorization', `Bearer ${token}`) - .query({ tokenType: TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION }); + .query({ tokenType: TOKEN_TYPE_ENUM.SIGN_UP }); expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); diff --git a/shared/contracts/auth.contract.ts b/shared/contracts/auth.contract.ts index 2c477c60..8daca1af 100644 --- a/shared/contracts/auth.contract.ts +++ b/shared/contracts/auth.contract.ts @@ -5,6 +5,7 @@ import { JSONAPIError } from "@shared/dtos/json-api.error"; import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema"; import { z } from "zod"; import { BearerTokenSchema } from "@shared/schemas/auth/bearer-token.schema"; +import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; // TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. @@ -19,6 +20,15 @@ export const authContract = contract.router({ }, body: LogInSchema, }, + signUp: { + method: "POST", + path: "/authentication/sign-up", + responses: { + 201: contract.type(), + 401: contract.type(), + }, + body: SignUpSchema, + }, validateToken: { method: "GET", path: "/authentication/validate-token", diff --git a/shared/schemas/auth/sign-up.schema.ts b/shared/schemas/auth/sign-up.schema.ts new file mode 100644 index 00000000..42ba70da --- /dev/null +++ b/shared/schemas/auth/sign-up.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; + +export const SignUpSchema = z.object({ + password: z + .string({ message: "Password is required" }) + .min(1, "Password is required") + .min(8, "Password must be more than 8 characters") + .max(32, "Password must be less than 32 characters"), + newPassword: z + .string({ message: "Password is required" }) + .min(1, "Password is required") + .min(8, "Password must be more than 8 characters") + .max(32, "Password must be less than 32 characters"), +}); + +export type SignUpDto = z.infer; diff --git a/shared/schemas/auth/token-type.schema.ts b/shared/schemas/auth/token-type.schema.ts index fda0a032..5ce98be0 100644 --- a/shared/schemas/auth/token-type.schema.ts +++ b/shared/schemas/auth/token-type.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export enum TOKEN_TYPE_ENUM { ACCESS = "access", RESET_PASSWORD = "reset-password", - EMAIL_CONFIRMATION = "email-confirmation", + SIGN_UP = "sign-up", } export const TokenTypeSchema = z.object({