From 1e999466bfb39ced235033b441c5fbd32bd41c47 Mon Sep 17 00:00:00 2001 From: Andrea Acampora Date: Fri, 21 Feb 2025 18:04:59 +0100 Subject: [PATCH] chore: add auth module and jwt service --- .env.dist | 5 + src/app.module.ts | 12 +-- src/config/env/configuration.constant.ts | 5 + src/libs/util/config.util.ts | 13 +++ .../interface/jwt-authentication.interface.ts | 17 ++++ .../presentation/body/login.body.ts | 29 ++++++ .../presentation/body/signup.body.ts | 12 +++ .../presentation/dto/auth-user.dto.ts | 5 + .../presentation/dto/jwt-user.dto.ts | 9 ++ .../auth/application/service/jwt.service.ts | 95 +++++++++++++++++++ src/modules/auth/auth.module.ts | 18 ++++ src/modules/user/domain/entity/user.entity.ts | 14 ++- .../domain/value-object/user-role.enum.ts | 4 + .../domain/value-object/user-state.enum.ts | 4 + 14 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 src/config/env/configuration.constant.ts create mode 100644 src/libs/util/config.util.ts create mode 100644 src/modules/auth/application/interface/jwt-authentication.interface.ts create mode 100644 src/modules/auth/application/presentation/body/login.body.ts create mode 100644 src/modules/auth/application/presentation/body/signup.body.ts create mode 100644 src/modules/auth/application/presentation/dto/auth-user.dto.ts create mode 100644 src/modules/auth/application/presentation/dto/jwt-user.dto.ts create mode 100644 src/modules/auth/application/service/jwt.service.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/user/domain/value-object/user-role.enum.ts create mode 100644 src/modules/user/domain/value-object/user-state.enum.ts diff --git a/.env.dist b/.env.dist index 71ad5c0..12482a7 100644 --- a/.env.dist +++ b/.env.dist @@ -4,4 +4,9 @@ DATABASE_PORT=15432 DATABASE_USER=postgres DATABASE_PASSWORD=postgres +JWT_SECRET=nestjs-ddd-devops +JWT_REFRESH_SECRET=nestjs-ddd-devops-refresh +JWT_EXPIRES_IN=3600 +JET_REFRESH_EXPIRES_IN=360000 + PORT=3000 \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 748955c..56a0b7d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,12 +4,12 @@ import { CacheModule } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import mikroOrmConfig from './config/database/mikro-orm.config'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule } from '@nestjs/throttler'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; -import { APP_GUARD } from '@nestjs/core'; import { UserModule } from './modules/user/user.module'; import { HealthModule } from './modules/health/health.module'; +import { AuthModule } from './modules/auth/auth.module'; @Module({ imports: [ @@ -42,13 +42,9 @@ import { HealthModule } from './modules/health/health.module'; ScheduleModule.forRoot(), HealthModule, UserModule, + AuthModule, ], controllers: [], - providers: [ - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], + providers: [], }) export class AppModule {} diff --git a/src/config/env/configuration.constant.ts b/src/config/env/configuration.constant.ts new file mode 100644 index 0000000..fde9cd4 --- /dev/null +++ b/src/config/env/configuration.constant.ts @@ -0,0 +1,5 @@ +export const JWT_SECRET = 'JWT_SECRET'; +export const JWT_REFRESH_SECRET = 'JWT_REFRESH_SECRET'; + +export const JWT_EXPIRES_IN = 'JWT_EXPIRES_IN'; +export const JWT_REFRESH_EXPIRES_IN = 'JWT_REFRESH_EXPIRES_IN'; diff --git a/src/libs/util/config.util.ts b/src/libs/util/config.util.ts new file mode 100644 index 0000000..918498d --- /dev/null +++ b/src/libs/util/config.util.ts @@ -0,0 +1,13 @@ +import { fromNullable, getOrThrowWith } from 'effect/Option'; +import { ConfigService } from '@nestjs/config'; + +export const getConfigValue = ( + configService: ConfigService, + key: string, +): T => { + console.log('AAA'); + return getOrThrowWith( + fromNullable(configService.get(key)), + () => new Error(`Missing ${key}`), + ); +}; diff --git a/src/modules/auth/application/interface/jwt-authentication.interface.ts b/src/modules/auth/application/interface/jwt-authentication.interface.ts new file mode 100644 index 0000000..7d2c0fb --- /dev/null +++ b/src/modules/auth/application/interface/jwt-authentication.interface.ts @@ -0,0 +1,17 @@ +import { AuthUser } from '../presentation/dto/auth-user.dto'; +import { Option } from 'effect/Option'; +import { JwtUser } from '../presentation/dto/jwt-user.dto'; + +export interface JwtAuthentication { + verifyToken(token: string): Promise>; + + verifyRefreshToken(refreshToken: string): Promise>; + + generateToken(user: AuthUser): Promise; + + generateRefreshToken(user: AuthUser): Promise; + + generateJwtUser(user: AuthUser): Promise; + + generateJwtUserFromRefresh(token: string): Promise; +} diff --git a/src/modules/auth/application/presentation/body/login.body.ts b/src/modules/auth/application/presentation/body/login.body.ts new file mode 100644 index 0000000..da7c2d7 --- /dev/null +++ b/src/modules/auth/application/presentation/body/login.body.ts @@ -0,0 +1,29 @@ +import { + IsEmail, + IsNotEmpty, + IsString, + IsStrongPassword, + MaxLength, +} from 'class-validator'; + +export class LoginBody { + @IsNotEmpty() + @IsEmail() + @IsString() + email!: string; + + @IsNotEmpty() + @IsString() + @MaxLength(20) + @IsStrongPassword( + { + minLength: 8, + minSymbols: 1, + }, + { + message: + 'Password must be at least 8 characters long and contain at least one symbol', + }, + ) + password!: string; +} diff --git a/src/modules/auth/application/presentation/body/signup.body.ts b/src/modules/auth/application/presentation/body/signup.body.ts new file mode 100644 index 0000000..567fef2 --- /dev/null +++ b/src/modules/auth/application/presentation/body/signup.body.ts @@ -0,0 +1,12 @@ +import { LoginBody } from './login.body'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SignupBody extends LoginBody { + @IsNotEmpty() + @IsString() + firstName!: string; + + @IsNotEmpty() + @IsString() + lastName!: string; +} diff --git a/src/modules/auth/application/presentation/dto/auth-user.dto.ts b/src/modules/auth/application/presentation/dto/auth-user.dto.ts new file mode 100644 index 0000000..33bd6ce --- /dev/null +++ b/src/modules/auth/application/presentation/dto/auth-user.dto.ts @@ -0,0 +1,5 @@ +export interface AuthUser { + id: string; + email: string; + role: number; +} diff --git a/src/modules/auth/application/presentation/dto/jwt-user.dto.ts b/src/modules/auth/application/presentation/dto/jwt-user.dto.ts new file mode 100644 index 0000000..b9c085f --- /dev/null +++ b/src/modules/auth/application/presentation/dto/jwt-user.dto.ts @@ -0,0 +1,9 @@ +import { AuthUser } from './auth-user.dto'; + +export interface JwtUser { + token: string; + expiresIn: number; + refreshToken: string; + refreshExpiresIn: number; + user: AuthUser; +} diff --git a/src/modules/auth/application/service/jwt.service.ts b/src/modules/auth/application/service/jwt.service.ts new file mode 100644 index 0000000..b33add7 --- /dev/null +++ b/src/modules/auth/application/service/jwt.service.ts @@ -0,0 +1,95 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + JWT_EXPIRES_IN, + JWT_REFRESH_EXPIRES_IN, + JWT_REFRESH_SECRET, + JWT_SECRET, +} from '../../../../config/env/configuration.constant'; +import { isNone, liftThrowable, Option } from 'effect/Option'; +import { JwtAuthentication } from '../interface/jwt-authentication.interface'; +import { AuthUser } from '../presentation/dto/auth-user.dto'; +import * as jwt from 'jsonwebtoken'; +import { getConfigValue } from '../../../../libs/util/config.util'; +import { JwtUser } from '../presentation/dto/jwt-user.dto'; + +@Injectable() +export class JwtService implements JwtAuthentication { + private readonly tokenSecret: string; + private readonly refreshTokenSecret: string; + private readonly tokenExpiration: number; + private readonly refreshTokenExpiration: number; + + constructor(private readonly configService: ConfigService) { + this.tokenSecret = getConfigValue(this.configService, JWT_SECRET); + this.refreshTokenSecret = getConfigValue( + this.configService, + JWT_REFRESH_SECRET, + ); + this.tokenExpiration = getConfigValue( + this.configService, + JWT_EXPIRES_IN, + ); + this.refreshTokenExpiration = getConfigValue( + this.configService, + JWT_REFRESH_EXPIRES_IN, + ); + } + + async generateToken(user: AuthUser): Promise { + return jwt.sign(user, this.tokenSecret, { + expiresIn: this.tokenExpiration, + }); + } + + async generateRefreshToken(user: AuthUser): Promise { + return jwt.sign(user, this.refreshTokenSecret, { + expiresIn: this.refreshTokenExpiration, + }); + } + + async generateJwtUser(authUser: AuthUser): Promise { + const token = await this.generateToken(authUser); + const refreshToken = await this.generateRefreshToken(authUser); + return { + token, + expiresIn: this.tokenExpiration, + refreshToken, + refreshExpiresIn: this.refreshTokenExpiration, + user: authUser, + }; + } + + async generateJwtUserFromRefresh(refreshToken: string): Promise { + const authUser = this.verifyJwt( + refreshToken, + this.refreshTokenSecret, + ); + if (isNone(authUser)) throw new UnauthorizedException('Invalid Token!'); + return this.generateJwtUser(this.convertToAuthUser(authUser.value)); + } + + async verifyToken(token: string): Promise> { + return this.verifyJwt(token, this.tokenSecret); + } + + async verifyRefreshToken(refreshToken: string): Promise> { + return this.verifyJwt(refreshToken, this.refreshTokenSecret); + } + + /** + * Generic JWT verification method. + */ + private verifyJwt(token: string, secret: string): Option { + return liftThrowable(() => jwt.verify(token, secret) as T)(); + } + + /** + * Helper method to clean the AuthUser object. + */ + convertToAuthUser = (authUser: AuthUser): AuthUser => ({ + id: authUser.id, + email: authUser.email, + role: authUser.role, + }); +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..6fd8aed --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { JwtService } from './application/service/jwt.service'; + +@Module({ + imports: [], + controllers: [], + providers: [ + JwtService, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], + exports: [], +}) +export class AuthModule {} diff --git a/src/modules/user/domain/entity/user.entity.ts b/src/modules/user/domain/entity/user.entity.ts index 1daaf04..853bc98 100644 --- a/src/modules/user/domain/entity/user.entity.ts +++ b/src/modules/user/domain/entity/user.entity.ts @@ -1,5 +1,7 @@ -import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { Entity, Enum, PrimaryKey, Property } from '@mikro-orm/core'; import { v4 } from 'uuid'; +import { UserRole } from '../value-object/user-role.enum'; +import { UserState } from '../value-object/user-state.enum'; @Entity({ tableName: 'Users', @@ -15,10 +17,16 @@ export class User { password!: string; @Property({ nullable: true }) - firstname?: string; + firstName?: string; @Property({ nullable: true }) - lastname?: string; + lastName?: string; + + @Enum({ items: () => UserRole }) + role!: UserRole; + + @Enum({ items: () => UserState }) + state!: UserState; @Property({ onCreate: () => new Date() }) createdAt: Date = new Date(); diff --git a/src/modules/user/domain/value-object/user-role.enum.ts b/src/modules/user/domain/value-object/user-role.enum.ts new file mode 100644 index 0000000..dfae48f --- /dev/null +++ b/src/modules/user/domain/value-object/user-role.enum.ts @@ -0,0 +1,4 @@ +export enum UserRole { + ADMIN = 0, + USER = 1, +} diff --git a/src/modules/user/domain/value-object/user-state.enum.ts b/src/modules/user/domain/value-object/user-state.enum.ts new file mode 100644 index 0000000..56429db --- /dev/null +++ b/src/modules/user/domain/value-object/user-state.enum.ts @@ -0,0 +1,4 @@ +export enum UserState { + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', +}