From ae53306b6e0b630447420d5cd53dfa9f905b2aaf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:06:37 +0000 Subject: [PATCH 1/3] feat: implement comprehensive authentication & authorization hardening - Add JWT refresh token rotation with RefreshTokenEntity and RefreshTokenService - Implement token blacklisting system with BlacklistedTokenEntity and TokenBlacklistService - Enhance password security with stronger validation rules (8+ chars, uppercase, lowercase, number, special char) - Add password history tracking with PasswordHistoryEntity to prevent reuse of last passwords - Implement session management with UserSessionEntity and device fingerprinting - Add account lockout mechanism with progressive delays after failed login attempts - Increase bcrypt salt rounds from 10 to 12 for stronger password hashing - Create database migration for new tables: refresh_tokens, blacklisted_tokens, password_history, user_sessions - Add new columns to users_auth table: failed_login_attempts, locked_until, last_password_change - Implement EnhancedJwtAuthGuard with token blacklist checking - Add TokenBlacklistMiddleware for request-level token validation - Create AuthCleanupCron for automated cleanup of expired tokens and sessions - Update JWT configuration for shorter access token expiration (30 minutes) - Add new authentication endpoints: refresh token, session management (list/revoke sessions) - Enhance auth controller with device info tracking and session management - Follow NestJS best practices and TypeORM patterns throughout implementation Co-Authored-By: Arthur Poon --- src/decorators/validators.decorator.ts | 5 +- src/guards/enhanced-jwt-auth.guard.ts | 29 +++++ src/middlewares/token-blacklist.middleware.ts | 22 ++++ .../1753739900000-auth-hardening.ts | 38 +++++++ .../auth/controllers/auth.controller.ts | 85 +++++++++++++- src/modules/auth/crons/auth-cleanup.cron.ts | 31 +++++ .../auth/dtos/enhanced-token-payload.dto.ts | 22 ++++ src/modules/auth/dtos/index.ts | 2 + .../auth/dtos/refresh-token-request.dto.ts | 9 ++ src/modules/auth/services/auth.service.ts | 106 +++++++++++++++--- src/modules/auth/services/index.ts | 2 + .../auth/services/refresh-token.service.ts | 77 +++++++++++++ .../auth/services/token-blacklist.service.ts | 39 +++++++ .../user/dtos/blacklisted-token.dto.ts | 17 +++ src/modules/user/dtos/index.ts | 4 + src/modules/user/dtos/password-history.dto.ts | 13 +++ src/modules/user/dtos/refresh-token.dto.ts | 25 +++++ src/modules/user/dtos/user-session.dto.ts | 33 ++++++ .../user/entities/blacklisted-token.entity.ts | 14 +++ src/modules/user/entities/index.ts | 4 + .../user/entities/password-history.entity.ts | 16 +++ .../user/entities/refresh-token.entity.ts | 25 +++++ src/modules/user/entities/user-auth.entity.ts | 9 ++ .../user/entities/user-session.entity.ts | 31 +++++ .../blacklisted-token.repository.ts | 6 + src/modules/user/repositories/index.ts | 4 + .../password-history.repository.ts | 6 + .../repositories/refresh-token.repository.ts | 6 + .../repositories/user-session.repository.ts | 6 + src/modules/user/services/index.ts | 2 + .../user/services/password-history.service.ts | 55 +++++++++ .../services/session-management.service.ts | 98 ++++++++++++++++ .../user/services/user-auth.service.ts | 32 +++++- src/utils/services/utils.service.ts | 2 +- 34 files changed, 856 insertions(+), 19 deletions(-) create mode 100644 src/guards/enhanced-jwt-auth.guard.ts create mode 100644 src/middlewares/token-blacklist.middleware.ts create mode 100644 src/migrations/1753739900000-auth-hardening.ts create mode 100644 src/modules/auth/crons/auth-cleanup.cron.ts create mode 100644 src/modules/auth/dtos/enhanced-token-payload.dto.ts create mode 100644 src/modules/auth/dtos/refresh-token-request.dto.ts create mode 100644 src/modules/auth/services/refresh-token.service.ts create mode 100644 src/modules/auth/services/token-blacklist.service.ts create mode 100644 src/modules/user/dtos/blacklisted-token.dto.ts create mode 100644 src/modules/user/dtos/password-history.dto.ts create mode 100644 src/modules/user/dtos/refresh-token.dto.ts create mode 100644 src/modules/user/dtos/user-session.dto.ts create mode 100644 src/modules/user/entities/blacklisted-token.entity.ts create mode 100644 src/modules/user/entities/password-history.entity.ts create mode 100644 src/modules/user/entities/refresh-token.entity.ts create mode 100644 src/modules/user/entities/user-session.entity.ts create mode 100644 src/modules/user/repositories/blacklisted-token.repository.ts create mode 100644 src/modules/user/repositories/password-history.repository.ts create mode 100644 src/modules/user/repositories/refresh-token.repository.ts create mode 100644 src/modules/user/repositories/user-session.repository.ts create mode 100644 src/modules/user/services/password-history.service.ts create mode 100644 src/modules/user/services/session-management.service.ts diff --git a/src/decorators/validators.decorator.ts b/src/decorators/validators.decorator.ts index 79d8815..d7e8628 100644 --- a/src/decorators/validators.decorator.ts +++ b/src/decorators/validators.decorator.ts @@ -16,7 +16,10 @@ export function IsPassword( options: validationOptions, validator: { validate(value: string, _args: ValidationArguments) { - return /^[a-zA-Z0-9!@#$%^&*]*$/.test(value); + const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; + const commonPasswords = ['password', '123456', 'qwerty', 'admin', 'letmein']; + + return strongPasswordRegex.test(value) && !commonPasswords.includes(value.toLowerCase()); }, }, }); diff --git a/src/guards/enhanced-jwt-auth.guard.ts b/src/guards/enhanced-jwt-auth.guard.ts new file mode 100644 index 0000000..bdda846 --- /dev/null +++ b/src/guards/enhanced-jwt-auth.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TokenBlacklistService } from 'modules/auth/services/token-blacklist.service'; + +@Injectable() +export class EnhancedJwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly _tokenBlacklistService: TokenBlacklistService) { + super(); + } + + async canActivate(context: any): Promise { + const request = context.switchToHttp().getRequest(); + const token = this._extractTokenFromHeader(request); + + if (token && await this._tokenBlacklistService.isTokenBlacklisted(token)) { + throw new UnauthorizedException('Token has been blacklisted'); + } + + return super.canActivate(context) as Promise; + } + + private _extractTokenFromHeader(request: any): string | null { + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + return authHeader.substring(7); + } +} diff --git a/src/middlewares/token-blacklist.middleware.ts b/src/middlewares/token-blacklist.middleware.ts new file mode 100644 index 0000000..eda8689 --- /dev/null +++ b/src/middlewares/token-blacklist.middleware.ts @@ -0,0 +1,22 @@ +import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { TokenBlacklistService } from 'modules/auth/services/token-blacklist.service'; + +@Injectable() +export class TokenBlacklistMiddleware implements NestMiddleware { + constructor(private readonly _tokenBlacklistService: TokenBlacklistService) {} + + async use(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + + if (await this._tokenBlacklistService.isTokenBlacklisted(token)) { + throw new UnauthorizedException('Token has been blacklisted'); + } + } + + next(); + } +} diff --git a/src/migrations/1753739900000-auth-hardening.ts b/src/migrations/1753739900000-auth-hardening.ts new file mode 100644 index 0000000..ae03122 --- /dev/null +++ b/src/migrations/1753739900000-auth-hardening.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AuthHardening1753739900000 implements MigrationInterface { + name = 'AuthHardening1753739900000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "refresh_tokens" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "token_hash" character varying NOT NULL, "device_info" jsonb, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "user_id" integer NOT NULL, CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`); + + await queryRunner.query(`CREATE TABLE "blacklisted_tokens" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "token_hash" character varying NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), CONSTRAINT "PK_48c4b5e5e4b8b7d4c8a5e5e4b8b" PRIMARY KEY ("id"))`); + + await queryRunner.query(`CREATE TABLE "password_history" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "password_hash" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "user_id" integer NOT NULL, CONSTRAINT "PK_48c4b5e5e4b8b7d4c8a5e5e4b8c" PRIMARY KEY ("id"))`); + + await queryRunner.query(`CREATE TABLE "user_sessions" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "session_token" character varying NOT NULL, "device_info" jsonb, "ip_address" inet, "user_agent" text, "last_activity" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "user_id" integer NOT NULL, CONSTRAINT "PK_48c4b5e5e4b8b7d4c8a5e5e4b8d" PRIMARY KEY ("id"))`); + + await queryRunner.query(`ALTER TABLE users_auth ADD COLUMN failed_login_attempts integer DEFAULT 0`); + await queryRunner.query(`ALTER TABLE users_auth ADD COLUMN locked_until TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE users_auth ADD COLUMN last_password_change TIMESTAMP WITH TIME ZONE`); + + await queryRunner.query(`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_refresh_tokens_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "password_history" ADD CONSTRAINT "FK_password_history_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_sessions" ADD CONSTRAINT "FK_user_sessions_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_sessions" DROP CONSTRAINT "FK_user_sessions_user_id"`); + await queryRunner.query(`ALTER TABLE "password_history" DROP CONSTRAINT "FK_password_history_user_id"`); + await queryRunner.query(`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_refresh_tokens_user_id"`); + + await queryRunner.query(`ALTER TABLE users_auth DROP COLUMN last_password_change`); + await queryRunner.query(`ALTER TABLE users_auth DROP COLUMN locked_until`); + await queryRunner.query(`ALTER TABLE users_auth DROP COLUMN failed_login_attempts`); + + await queryRunner.query(`DROP TABLE "user_sessions"`); + await queryRunner.query(`DROP TABLE "password_history"`); + await queryRunner.query(`DROP TABLE "blacklisted_tokens"`); + await queryRunner.query(`DROP TABLE "refresh_tokens"`); + } +} diff --git a/src/modules/auth/controllers/auth.controller.ts b/src/modules/auth/controllers/auth.controller.ts index 7160b79..f9186b8 100644 --- a/src/modules/auth/controllers/auth.controller.ts +++ b/src/modules/auth/controllers/auth.controller.ts @@ -1,8 +1,11 @@ import { Body, Controller, + Delete, + Get, HttpCode, HttpStatus, + Param, Patch, Post, Req, @@ -25,6 +28,8 @@ import { UserLoginDto, UserRegisterDto, UserResetPasswordDto, + EnhancedTokenPayloadDto, + RefreshTokenRequestDto, } from 'modules/auth/dtos'; import { AuthService } from 'modules/auth/services'; import { UserDto } from 'modules/user/dtos'; @@ -50,9 +55,16 @@ export class AuthController { }) async userLogin( @Body() userLoginDto: UserLoginDto, + @Req() req: any, ): Promise { const user = await this._authService.validateUser(userLoginDto); - const token = await this._authService.createToken(user); + + const deviceInfo = { + userAgent: req.headers['user-agent'], + ip: req.ip, + }; + + const token = await this._authService.createToken(user, deviceInfo); return new LoginPayloadDto(user.toDto(), token); } @@ -80,8 +92,9 @@ export class AuthController { @UseInterceptors(AuthUserInterceptor) @ApiBearerAuth() @Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT) - async userLogout(@AuthUser() user: UserEntity): Promise { - await this._userAuthService.updateLastLogoutDate(user.userAuth); + async userLogout(@AuthUser() user: UserEntity, @Req() req: any): Promise { + const token = req.headers.authorization?.substring(7); + await this._authService.logout(user, token); } @Post('password/forget') @@ -111,4 +124,70 @@ export class AuthController { ) { return this._authService.handleResetPassword(password, user); } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + status: HttpStatus.OK, + type: EnhancedTokenPayloadDto, + description: 'New access and refresh tokens', + }) + async refreshToken( + @Body() refreshTokenRequestDto: RefreshTokenRequestDto, + @Req() req: any, + ): Promise { + const deviceInfo = { + userAgent: req.headers['user-agent'], + ip: req.ip, + }; + + return this._authService.refreshToken(refreshTokenRequestDto, deviceInfo); + } + + @Get('sessions') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + status: HttpStatus.OK, + description: 'User sessions list', + }) + @UseGuards(AuthGuard, RolesGuard) + @UseInterceptors(AuthUserInterceptor) + @ApiBearerAuth() + @Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT) + async getUserSessions(@AuthUser() user: UserEntity) { + return this._userService.getUserSessions(user); + } + + @Delete('sessions/:sessionToken') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ + description: 'Session revoked successfully', + }) + @UseGuards(AuthGuard, RolesGuard) + @UseInterceptors(AuthUserInterceptor) + @ApiBearerAuth() + @Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT) + async revokeSession( + @AuthUser() user: UserEntity, + @Param('sessionToken') sessionToken: string, + ): Promise { + return this._userService.revokeUserSession(user, sessionToken); + } + + @Delete('sessions') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ + description: 'All sessions revoked successfully', + }) + @UseGuards(AuthGuard, RolesGuard) + @UseInterceptors(AuthUserInterceptor) + @ApiBearerAuth() + @Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT) + async revokeAllSessions( + @AuthUser() user: UserEntity, + @Req() req: any, + ): Promise { + const currentSessionToken = req.headers.authorization?.substring(7); + return this._userService.revokeAllUserSessions(user, currentSessionToken); + } } diff --git a/src/modules/auth/crons/auth-cleanup.cron.ts b/src/modules/auth/crons/auth-cleanup.cron.ts new file mode 100644 index 0000000..a64e8dc --- /dev/null +++ b/src/modules/auth/crons/auth-cleanup.cron.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { RefreshTokenService } from '../services/refresh-token.service'; +import { TokenBlacklistService } from '../services/token-blacklist.service'; +import { SessionManagementService } from 'modules/user/services/session-management.service'; + +@Injectable() +export class AuthCleanupCron { + private readonly _logger = new Logger(AuthCleanupCron.name); + + constructor( + private readonly _refreshTokenService: RefreshTokenService, + private readonly _tokenBlacklistService: TokenBlacklistService, + private readonly _sessionManagementService: SessionManagementService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_2AM) + public async cleanupExpiredTokensAndSessions(): Promise { + try { + await Promise.all([ + this._refreshTokenService.cleanupExpiredTokens(), + this._tokenBlacklistService.cleanupExpiredTokens(), + this._sessionManagementService.cleanupExpiredSessions(), + ]); + + this._logger.log('Successfully cleaned up expired tokens and sessions'); + } catch (error) { + this._logger.error('Failed to cleanup expired tokens and sessions', error); + } + } +} diff --git a/src/modules/auth/dtos/enhanced-token-payload.dto.ts b/src/modules/auth/dtos/enhanced-token-payload.dto.ts new file mode 100644 index 0000000..c763c2f --- /dev/null +++ b/src/modules/auth/dtos/enhanced-token-payload.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class EnhancedTokenPayloadDto { + @ApiProperty() + readonly accessToken: string; + + @ApiProperty() + readonly refreshToken: string; + + @ApiProperty() + readonly expiresIn: number; + + @ApiProperty() + readonly refreshExpiresIn: number; + + constructor(data: { accessToken: string; refreshToken: string; expiresIn: number; refreshExpiresIn: number }) { + this.accessToken = data.accessToken; + this.refreshToken = data.refreshToken; + this.expiresIn = data.expiresIn; + this.refreshExpiresIn = data.refreshExpiresIn; + } +} diff --git a/src/modules/auth/dtos/index.ts b/src/modules/auth/dtos/index.ts index 744cd06..48a1af6 100644 --- a/src/modules/auth/dtos/index.ts +++ b/src/modules/auth/dtos/index.ts @@ -5,3 +5,5 @@ export * from './user-register.dto'; export * from './user-forgotten-password.dto'; export * from './user-reset-password.dto'; export * from './forgotten-password-payload.dto'; +export * from './enhanced-token-payload.dto'; +export * from './refresh-token-request.dto'; diff --git a/src/modules/auth/dtos/refresh-token-request.dto.ts b/src/modules/auth/dtos/refresh-token-request.dto.ts new file mode 100644 index 0000000..93ba35c --- /dev/null +++ b/src/modules/auth/dtos/refresh-token-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RefreshTokenRequestDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly refreshToken: string; +} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index c661c72..76466a9 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -9,6 +9,8 @@ import { TokenPayloadDto, UserForgottenPasswordDto, UserLoginDto, + EnhancedTokenPayloadDto, + RefreshTokenRequestDto, } from 'modules/auth/dtos'; import { UserAuthForgottenPasswordEntity, @@ -18,6 +20,8 @@ import { UserAuthForgottenPasswordService, UserAuthService, UserService, + PasswordHistoryService, + SessionManagementService, } from 'modules/user/services'; import { ContextService } from 'providers'; import { UtilsService } from 'utils/services'; @@ -26,6 +30,8 @@ import { ForgottenTokenHasUsedException, WrongCredentialsProvidedException, } from '../exceptions'; +import { RefreshTokenService } from './refresh-token.service'; +import { TokenBlacklistService } from './token-blacklist.service'; @Injectable() export class AuthService { @@ -37,17 +43,36 @@ export class AuthService { private readonly _userService: UserService, private readonly _userAuthService: UserAuthService, private readonly _userAuthForgottenPasswordService: UserAuthForgottenPasswordService, + private readonly _refreshTokenService: RefreshTokenService, + private readonly _tokenBlacklistService: TokenBlacklistService, + private readonly _passwordHistoryService: PasswordHistoryService, + private readonly _sessionManagementService: SessionManagementService, ) {} - public async createToken(user: UserEntity): Promise { + public async createToken(user: UserEntity, deviceInfo?: any): Promise { const { uuid, userAuth: { role }, } = user; - return new TokenPayloadDto({ - expiresIn: this._configService.get('JWT_EXPIRATION_TIME'), - accessToken: await this._jwtService.signAsync({ uuid, role }), + const accessTokenExpiration = 1800; + const refreshTokenExpiration = 30 * 24 * 60 * 60; + + const accessToken = await this._jwtService.signAsync( + { uuid, role }, + { expiresIn: accessTokenExpiration } + ); + + const { token: refreshToken } = await this._refreshTokenService.createRefreshToken( + user, + deviceInfo + ); + + return new EnhancedTokenPayloadDto({ + accessToken, + refreshToken, + expiresIn: accessTokenExpiration, + refreshExpiresIn: refreshTokenExpiration, }); } @@ -59,20 +84,27 @@ export class AuthService { throw new UserNotFoundException(); } + if (user.userAuth.lockedUntil && user.userAuth.lockedUntil > new Date()) { + throw new UserPasswordNotValidException(); + } + const isPasswordValid = await UtilsService.validateHash( password, user.userAuth.password, ); + if (!isPasswordValid) { + await this._userAuthService.incrementFailedLoginAttempts(user.userAuth); + throw new UserPasswordNotValidException(); + } + + await this._userAuthService.resetFailedLoginAttempts(user.userAuth); + user = await this._userAuthService.updateLastLoggedDate( user, isPasswordValid, ); - if (!isPasswordValid) { - throw new UserPasswordNotValidException(); - } - return user; } @@ -104,15 +136,17 @@ export class AuthService { password: string, userAuthForgottenPasswordEntity: UserAuthForgottenPasswordEntity, ): Promise { - console.log( - 'userAuthForgottenPasswordEntity', - userAuthForgottenPasswordEntity, - ); - if (userAuthForgottenPasswordEntity.used) { throw new ForgottenTokenHasUsedException(); } + const user = userAuthForgottenPasswordEntity.user; + + const isPasswordReused = await this._passwordHistoryService.isPasswordReused(user, password); + if (isPasswordReused) { + throw new WrongCredentialsProvidedException(); + } + await Promise.all([ this._userAuthForgottenPasswordService.changeTokenActiveStatus( userAuthForgottenPasswordEntity, @@ -122,6 +156,7 @@ export class AuthService { userAuthForgottenPasswordEntity.user.userAuth, password, ), + this._passwordHistoryService.addPasswordToHistory(user, password), ]); } @@ -173,4 +208,49 @@ export class AuthService { return token; } + + public async refreshToken(refreshTokenRequestDto: RefreshTokenRequestDto, deviceInfo?: any): Promise { + const { refreshToken } = refreshTokenRequestDto; + + const refreshTokenEntity = await this._refreshTokenService.validateRefreshToken(refreshToken); + if (!refreshTokenEntity) { + throw new WrongCredentialsProvidedException(); + } + + const { token: newRefreshToken } = await this._refreshTokenService.rotateRefreshToken( + refreshTokenEntity, + deviceInfo + ); + + const { + uuid, + userAuth: { role }, + } = refreshTokenEntity.user; + + const accessTokenExpiration = 1800; + const refreshTokenExpiration = 30 * 24 * 60 * 60; + + const accessToken = await this._jwtService.signAsync( + { uuid, role }, + { expiresIn: accessTokenExpiration } + ); + + return new EnhancedTokenPayloadDto({ + accessToken, + refreshToken: newRefreshToken, + expiresIn: accessTokenExpiration, + refreshExpiresIn: refreshTokenExpiration, + }); + } + + public async logout(user: UserEntity, accessToken: string): Promise { + const tokenExpiration = new Date(); + tokenExpiration.setHours(tokenExpiration.getHours() + 1); + + await Promise.all([ + this._tokenBlacklistService.blacklistToken(accessToken, tokenExpiration), + this._refreshTokenService.revokeAllUserRefreshTokens(user), + this._userAuthService.updateLastLogoutDate(user.userAuth), + ]); + } } diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts index 2a719d1..f28eb0c 100644 --- a/src/modules/auth/services/index.ts +++ b/src/modules/auth/services/index.ts @@ -1 +1,3 @@ export * from './auth.service'; +export * from './refresh-token.service'; +export * from './token-blacklist.service'; diff --git a/src/modules/auth/services/refresh-token.service.ts b/src/modules/auth/services/refresh-token.service.ts new file mode 100644 index 0000000..605e1ce --- /dev/null +++ b/src/modules/auth/services/refresh-token.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RefreshTokenEntity, UserEntity } from 'modules/user/entities'; +import { RefreshTokenRepository } from 'modules/user/repositories'; +import { UtilsService } from 'utils/services'; +import { MoreThan, LessThan } from 'typeorm'; + +@Injectable() +export class RefreshTokenService { + constructor( + private readonly _refreshTokenRepository: RefreshTokenRepository, + private readonly _configService: ConfigService, + ) {} + + async createRefreshToken( + user: UserEntity, + deviceInfo?: any, + ): Promise<{ token: string; entity: RefreshTokenEntity }> { + const token = UtilsService.generateRandomString(64); + const tokenHash = UtilsService.encodeString(token); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const refreshTokenEntity = this._refreshTokenRepository.create({ + tokenHash, + user, + deviceInfo, + expiresAt, + }); + + await this._refreshTokenRepository.save(refreshTokenEntity); + + return { token, entity: refreshTokenEntity }; + } + + async validateRefreshToken(token: string): Promise { + const tokenHash = UtilsService.encodeString(token); + + const refreshToken = await this._refreshTokenRepository.findOne({ + where: { + tokenHash, + revokedAt: null, + expiresAt: MoreThan(new Date()), + }, + relations: ['user', 'user.userAuth'], + }); + + return refreshToken || null; + } + + async revokeRefreshToken(refreshToken: RefreshTokenEntity): Promise { + refreshToken.revokedAt = new Date(); + await this._refreshTokenRepository.save(refreshToken); + } + + async revokeAllUserRefreshTokens(user: UserEntity): Promise { + await this._refreshTokenRepository.update( + { user, revokedAt: null }, + { revokedAt: new Date() }, + ); + } + + async rotateRefreshToken( + oldRefreshToken: RefreshTokenEntity, + deviceInfo?: any, + ): Promise<{ token: string; entity: RefreshTokenEntity }> { + await this.revokeRefreshToken(oldRefreshToken); + return this.createRefreshToken(oldRefreshToken.user, deviceInfo); + } + + async cleanupExpiredTokens(): Promise { + await this._refreshTokenRepository.delete({ + expiresAt: LessThan(new Date()), + }); + } +} diff --git a/src/modules/auth/services/token-blacklist.service.ts b/src/modules/auth/services/token-blacklist.service.ts new file mode 100644 index 0000000..391ef6e --- /dev/null +++ b/src/modules/auth/services/token-blacklist.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { BlacklistedTokenEntity } from 'modules/user/entities'; +import { BlacklistedTokenRepository } from 'modules/user/repositories'; +import { UtilsService } from 'utils/services'; +import { LessThan } from 'typeorm'; + +@Injectable() +export class TokenBlacklistService { + constructor( + private readonly _blacklistedTokenRepository: BlacklistedTokenRepository, + ) {} + + async blacklistToken(token: string, expiresAt: Date): Promise { + const tokenHash = UtilsService.encodeString(token); + + const blacklistedToken = this._blacklistedTokenRepository.create({ + tokenHash, + expiresAt, + }); + + await this._blacklistedTokenRepository.save(blacklistedToken); + } + + async isTokenBlacklisted(token: string): Promise { + const tokenHash = UtilsService.encodeString(token); + + const blacklistedToken = await this._blacklistedTokenRepository.findOne({ + where: { tokenHash }, + }); + + return !!blacklistedToken; + } + + async cleanupExpiredTokens(): Promise { + await this._blacklistedTokenRepository.delete({ + expiresAt: LessThan(new Date()), + }); + } +} diff --git a/src/modules/user/dtos/blacklisted-token.dto.ts b/src/modules/user/dtos/blacklisted-token.dto.ts new file mode 100644 index 0000000..49fdc94 --- /dev/null +++ b/src/modules/user/dtos/blacklisted-token.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AbstractDto } from 'common/dtos'; +import { BlacklistedTokenEntity } from '../entities'; + +export class BlacklistedTokenDto extends AbstractDto { + @ApiProperty() + tokenHash: string; + + @ApiProperty() + expiresAt: Date; + + constructor(blacklistedToken: BlacklistedTokenEntity) { + super(blacklistedToken); + this.tokenHash = blacklistedToken.tokenHash; + this.expiresAt = blacklistedToken.expiresAt; + } +} diff --git a/src/modules/user/dtos/index.ts b/src/modules/user/dtos/index.ts index 606ac16..871fcd5 100644 --- a/src/modules/user/dtos/index.ts +++ b/src/modules/user/dtos/index.ts @@ -6,3 +6,7 @@ export * from './users-page-options.dto'; export * from './users-page.dto'; export * from './user-auth-forgotten-password.dto'; export * from './forgotten-password-create.dto'; +export * from './refresh-token.dto'; +export * from './blacklisted-token.dto'; +export * from './password-history.dto'; +export * from './user-session.dto'; diff --git a/src/modules/user/dtos/password-history.dto.ts b/src/modules/user/dtos/password-history.dto.ts new file mode 100644 index 0000000..77296a7 --- /dev/null +++ b/src/modules/user/dtos/password-history.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AbstractDto } from 'common/dtos'; +import { PasswordHistoryEntity } from '../entities'; + +export class PasswordHistoryDto extends AbstractDto { + @ApiProperty() + passwordHash: string; + + constructor(passwordHistory: PasswordHistoryEntity) { + super(passwordHistory); + this.passwordHash = passwordHistory.passwordHash; + } +} diff --git a/src/modules/user/dtos/refresh-token.dto.ts b/src/modules/user/dtos/refresh-token.dto.ts new file mode 100644 index 0000000..3d0a5e1 --- /dev/null +++ b/src/modules/user/dtos/refresh-token.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AbstractDto } from 'common/dtos'; +import { RefreshTokenEntity } from '../entities'; + +export class RefreshTokenDto extends AbstractDto { + @ApiProperty() + tokenHash: string; + + @ApiProperty() + deviceInfo: any; + + @ApiProperty() + expiresAt: Date; + + @ApiProperty() + revokedAt?: Date; + + constructor(refreshToken: RefreshTokenEntity) { + super(refreshToken); + this.tokenHash = refreshToken.tokenHash; + this.deviceInfo = refreshToken.deviceInfo; + this.expiresAt = refreshToken.expiresAt; + this.revokedAt = refreshToken.revokedAt; + } +} diff --git a/src/modules/user/dtos/user-session.dto.ts b/src/modules/user/dtos/user-session.dto.ts new file mode 100644 index 0000000..f854315 --- /dev/null +++ b/src/modules/user/dtos/user-session.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AbstractDto } from 'common/dtos'; +import { UserSessionEntity } from '../entities'; + +export class UserSessionDto extends AbstractDto { + @ApiProperty() + sessionToken: string; + + @ApiProperty() + deviceInfo: any; + + @ApiProperty() + ipAddress: string; + + @ApiProperty() + userAgent: string; + + @ApiProperty() + lastActivity: Date; + + @ApiProperty() + expiresAt: Date; + + constructor(userSession: UserSessionEntity) { + super(userSession); + this.sessionToken = userSession.sessionToken; + this.deviceInfo = userSession.deviceInfo; + this.ipAddress = userSession.ipAddress; + this.userAgent = userSession.userAgent; + this.lastActivity = userSession.lastActivity; + this.expiresAt = userSession.expiresAt; + } +} diff --git a/src/modules/user/entities/blacklisted-token.entity.ts b/src/modules/user/entities/blacklisted-token.entity.ts new file mode 100644 index 0000000..5b7698a --- /dev/null +++ b/src/modules/user/entities/blacklisted-token.entity.ts @@ -0,0 +1,14 @@ +import { AbstractEntity } from 'common/entities'; +import { BlacklistedTokenDto } from '../dtos'; +import { Column, Entity } from 'typeorm'; + +@Entity({ name: 'blacklisted_tokens' }) +export class BlacklistedTokenEntity extends AbstractEntity { + @Column() + tokenHash: string; + + @Column({ type: 'timestamp with time zone' }) + expiresAt: Date; + + dtoClass = BlacklistedTokenDto; +} diff --git a/src/modules/user/entities/index.ts b/src/modules/user/entities/index.ts index 79f37ae..0f796eb 100644 --- a/src/modules/user/entities/index.ts +++ b/src/modules/user/entities/index.ts @@ -2,3 +2,7 @@ export * from './user.entity'; export * from './user-auth.entity'; export * from './user-config.entity'; export * from './user-auth-forgotten-password.entity'; +export * from './refresh-token.entity'; +export * from './blacklisted-token.entity'; +export * from './password-history.entity'; +export * from './user-session.entity'; diff --git a/src/modules/user/entities/password-history.entity.ts b/src/modules/user/entities/password-history.entity.ts new file mode 100644 index 0000000..655ba60 --- /dev/null +++ b/src/modules/user/entities/password-history.entity.ts @@ -0,0 +1,16 @@ +import { AbstractEntity } from 'common/entities'; +import { PasswordHistoryDto } from '../dtos'; +import { UserEntity } from './user.entity'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; + +@Entity({ name: 'password_history' }) +export class PasswordHistoryEntity extends AbstractEntity { + @Column() + passwordHash: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn() + user: UserEntity; + + dtoClass = PasswordHistoryDto; +} diff --git a/src/modules/user/entities/refresh-token.entity.ts b/src/modules/user/entities/refresh-token.entity.ts new file mode 100644 index 0000000..855477d --- /dev/null +++ b/src/modules/user/entities/refresh-token.entity.ts @@ -0,0 +1,25 @@ +import { AbstractEntity } from 'common/entities'; +import { RefreshTokenDto } from '../dtos'; +import { UserEntity } from './user.entity'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; + +@Entity({ name: 'refresh_tokens' }) +export class RefreshTokenEntity extends AbstractEntity { + @Column() + tokenHash: string; + + @Column({ type: 'jsonb', nullable: true }) + deviceInfo: any; + + @Column({ type: 'timestamp with time zone' }) + expiresAt: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + revokedAt?: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn() + user: UserEntity; + + dtoClass = RefreshTokenDto; +} diff --git a/src/modules/user/entities/user-auth.entity.ts b/src/modules/user/entities/user-auth.entity.ts index 41f7744..940d9d7 100644 --- a/src/modules/user/entities/user-auth.entity.ts +++ b/src/modules/user/entities/user-auth.entity.ts @@ -30,6 +30,15 @@ export class UserAuthEntity extends AbstractEntity { @Column({ nullable: true }) lastLogoutDate?: Date; + @Column({ type: 'integer', default: 0 }) + failedLoginAttempts: number; + + @Column({ type: 'timestamp with time zone', nullable: true }) + lockedUntil?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + lastPasswordChange?: Date; + @UpdateDateColumn({ type: 'timestamp with time zone', nullable: true, diff --git a/src/modules/user/entities/user-session.entity.ts b/src/modules/user/entities/user-session.entity.ts new file mode 100644 index 0000000..fe39ac7 --- /dev/null +++ b/src/modules/user/entities/user-session.entity.ts @@ -0,0 +1,31 @@ +import { AbstractEntity } from 'common/entities'; +import { UserSessionDto } from '../dtos'; +import { UserEntity } from './user.entity'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; + +@Entity({ name: 'user_sessions' }) +export class UserSessionEntity extends AbstractEntity { + @Column() + sessionToken: string; + + @Column({ type: 'jsonb', nullable: true }) + deviceInfo: any; + + @Column({ type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ type: 'text', nullable: true }) + userAgent: string; + + @Column({ type: 'timestamp with time zone' }) + lastActivity: Date; + + @Column({ type: 'timestamp with time zone' }) + expiresAt: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn() + user: UserEntity; + + dtoClass = UserSessionDto; +} diff --git a/src/modules/user/repositories/blacklisted-token.repository.ts b/src/modules/user/repositories/blacklisted-token.repository.ts new file mode 100644 index 0000000..c3f2b7f --- /dev/null +++ b/src/modules/user/repositories/blacklisted-token.repository.ts @@ -0,0 +1,6 @@ +import { BlacklistedTokenEntity } from '../entities'; +import { Repository } from 'typeorm'; +import { EntityRepository } from 'typeorm/decorator/EntityRepository'; + +@EntityRepository(BlacklistedTokenEntity) +export class BlacklistedTokenRepository extends Repository {} diff --git a/src/modules/user/repositories/index.ts b/src/modules/user/repositories/index.ts index e512964..4ebb133 100644 --- a/src/modules/user/repositories/index.ts +++ b/src/modules/user/repositories/index.ts @@ -2,3 +2,7 @@ export * from './user.repository'; export * from './user-auth.repository'; export * from './user-config.repository'; export * from './user-auth-forgotten-password.repository'; +export * from './refresh-token.repository'; +export * from './blacklisted-token.repository'; +export * from './password-history.repository'; +export * from './user-session.repository'; diff --git a/src/modules/user/repositories/password-history.repository.ts b/src/modules/user/repositories/password-history.repository.ts new file mode 100644 index 0000000..1614982 --- /dev/null +++ b/src/modules/user/repositories/password-history.repository.ts @@ -0,0 +1,6 @@ +import { PasswordHistoryEntity } from '../entities'; +import { Repository } from 'typeorm'; +import { EntityRepository } from 'typeorm/decorator/EntityRepository'; + +@EntityRepository(PasswordHistoryEntity) +export class PasswordHistoryRepository extends Repository {} diff --git a/src/modules/user/repositories/refresh-token.repository.ts b/src/modules/user/repositories/refresh-token.repository.ts new file mode 100644 index 0000000..5575f7f --- /dev/null +++ b/src/modules/user/repositories/refresh-token.repository.ts @@ -0,0 +1,6 @@ +import { RefreshTokenEntity } from '../entities'; +import { Repository } from 'typeorm'; +import { EntityRepository } from 'typeorm/decorator/EntityRepository'; + +@EntityRepository(RefreshTokenEntity) +export class RefreshTokenRepository extends Repository {} diff --git a/src/modules/user/repositories/user-session.repository.ts b/src/modules/user/repositories/user-session.repository.ts new file mode 100644 index 0000000..16d1fc3 --- /dev/null +++ b/src/modules/user/repositories/user-session.repository.ts @@ -0,0 +1,6 @@ +import { UserSessionEntity } from '../entities'; +import { Repository } from 'typeorm'; +import { EntityRepository } from 'typeorm/decorator/EntityRepository'; + +@EntityRepository(UserSessionEntity) +export class UserSessionRepository extends Repository {} diff --git a/src/modules/user/services/index.ts b/src/modules/user/services/index.ts index 31b30db..6c700ed 100644 --- a/src/modules/user/services/index.ts +++ b/src/modules/user/services/index.ts @@ -2,3 +2,5 @@ export * from './user.service'; export * from './user-auth.service'; export * from './user-config.service'; export * from './user-auth-forgotten-password.service'; +export * from './password-history.service'; +export * from './session-management.service'; diff --git a/src/modules/user/services/password-history.service.ts b/src/modules/user/services/password-history.service.ts new file mode 100644 index 0000000..399ef43 --- /dev/null +++ b/src/modules/user/services/password-history.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { PasswordHistoryEntity, UserEntity } from 'modules/user/entities'; +import { PasswordHistoryRepository } from 'modules/user/repositories'; +import { UtilsService } from 'utils/services'; + +@Injectable() +export class PasswordHistoryService { + private readonly PASSWORD_HISTORY_LIMIT = 10; + + constructor( + private readonly _passwordHistoryRepository: PasswordHistoryRepository, + ) {} + + async addPasswordToHistory(user: UserEntity, password: string): Promise { + const passwordHash = UtilsService.generateHash(password); + + const passwordHistory = this._passwordHistoryRepository.create({ + user, + passwordHash, + }); + + await this._passwordHistoryRepository.save(passwordHistory); + await this._cleanupOldPasswords(user); + } + + async isPasswordReused(user: UserEntity, password: string): Promise { + const recentPasswords = await this._passwordHistoryRepository.find({ + where: { user }, + order: { createdAt: 'DESC' }, + take: this.PASSWORD_HISTORY_LIMIT, + }); + + for (const passwordHistory of recentPasswords) { + const isMatch = await UtilsService.validateHash(password, passwordHistory.passwordHash); + if (isMatch) { + return true; + } + } + + return false; + } + + private async _cleanupOldPasswords(user: UserEntity): Promise { + const allPasswords = await this._passwordHistoryRepository.find({ + where: { user }, + order: { createdAt: 'DESC' }, + }); + + if (allPasswords.length > this.PASSWORD_HISTORY_LIMIT) { + const passwordsToDelete = allPasswords.slice(this.PASSWORD_HISTORY_LIMIT); + const idsToDelete = passwordsToDelete.map(p => p.id); + await this._passwordHistoryRepository.delete(idsToDelete); + } + } +} diff --git a/src/modules/user/services/session-management.service.ts b/src/modules/user/services/session-management.service.ts new file mode 100644 index 0000000..e1618a4 --- /dev/null +++ b/src/modules/user/services/session-management.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { UserEntity, UserSessionEntity } from 'modules/user/entities'; +import { UserSessionRepository } from 'modules/user/repositories'; +import { UtilsService } from 'utils/services'; +import { MoreThan, LessThan } from 'typeorm'; + +@Injectable() +export class SessionManagementService { + private readonly MAX_CONCURRENT_SESSIONS = 5; + + constructor( + private readonly _userSessionRepository: UserSessionRepository, + private readonly _configService: ConfigService, + ) {} + + async createSession( + user: UserEntity, + deviceInfo: any, + ipAddress: string, + userAgent: string, + ): Promise { + const sessionToken = UtilsService.generateRandomString(64); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const session = this._userSessionRepository.create({ + user, + sessionToken, + deviceInfo, + ipAddress, + userAgent, + lastActivity: new Date(), + expiresAt, + }); + + await this._userSessionRepository.save(session); + await this._enforceSessionLimit(user); + + return session; + } + + async updateSessionActivity(sessionToken: string): Promise { + await this._userSessionRepository.update( + { sessionToken }, + { lastActivity: new Date() }, + ); + } + + async getUserSessions(user: UserEntity): Promise { + return this._userSessionRepository.find({ + where: { + user, + expiresAt: MoreThan(new Date()), + }, + order: { lastActivity: 'DESC' }, + }); + } + + async revokeSession(sessionToken: string): Promise { + await this._userSessionRepository.delete({ sessionToken }); + } + + async revokeAllUserSessions(user: UserEntity, exceptSessionToken?: string): Promise { + const query = this._userSessionRepository.createQueryBuilder() + .delete() + .where('user_id = :userId', { userId: user.id }); + + if (exceptSessionToken) { + query.andWhere('session_token != :sessionToken', { sessionToken: exceptSessionToken }); + } + + await query.execute(); + } + + async cleanupExpiredSessions(): Promise { + await this._userSessionRepository.delete({ + expiresAt: LessThan(new Date()), + }); + } + + private async _enforceSessionLimit(user: UserEntity): Promise { + const sessions = await this._userSessionRepository.find({ + where: { + user, + expiresAt: MoreThan(new Date()), + }, + order: { lastActivity: 'DESC' }, + }); + + if (sessions.length > this.MAX_CONCURRENT_SESSIONS) { + const sessionsToDelete = sessions.slice(this.MAX_CONCURRENT_SESSIONS); + const tokensToDelete = sessionsToDelete.map(s => s.sessionToken); + await this._userSessionRepository.delete({ sessionToken: tokensToDelete }); + } + } +} diff --git a/src/modules/user/services/user-auth.service.ts b/src/modules/user/services/user-auth.service.ts index b70ac81..46ac93c 100644 --- a/src/modules/user/services/user-auth.service.ts +++ b/src/modules/user/services/user-auth.service.ts @@ -84,13 +84,19 @@ export class UserAuthService { userAuth: UserAuthEntity, password: string, ): Promise { + const hashedPassword = UtilsService.generateHash(password); const queryBuilder = this._userAuthRepository.createQueryBuilder( 'userAuth', ); return queryBuilder .update() - .set({ password }) + .set({ + password: hashedPassword, + lastPasswordChange: new Date(), + failedLoginAttempts: 0, + lockedUntil: null, + }) .where('id = :id', { id: userAuth.id }) .execute(); } @@ -172,4 +178,28 @@ export class UserAuthService { .where('id = :id', { id: userAuth.id }) .execute(); } + + public async incrementFailedLoginAttempts(userAuth: UserAuthEntity): Promise { + const newAttempts = userAuth.failedLoginAttempts + 1; + const lockoutThreshold = 5; + + let lockedUntil = null; + if (newAttempts >= lockoutThreshold) { + lockedUntil = new Date(); + lockedUntil.setMinutes(lockedUntil.getMinutes() + Math.pow(2, newAttempts - lockoutThreshold) * 15); + } + + await this._userAuthRepository.update(userAuth.id, { + failedLoginAttempts: newAttempts, + lockedUntil, + lastFailedLoggedDate: new Date(), + }); + } + + public async resetFailedLoginAttempts(userAuth: UserAuthEntity): Promise { + await this._userAuthRepository.update(userAuth.id, { + failedLoginAttempts: 0, + lockedUntil: null, + }); + } } diff --git a/src/utils/services/utils.service.ts b/src/utils/services/utils.service.ts index 451aba1..58e5a0e 100644 --- a/src/utils/services/utils.service.ts +++ b/src/utils/services/utils.service.ts @@ -26,7 +26,7 @@ export class UtilsService { } static generateHash(password: string): string { - return bcrypt.hashSync(password, 10); + return bcrypt.hashSync(password, 12); } static validateHash(password: string, hash: string): Promise { From a12dbeb17409257a7304f916f452327317600de5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:12:19 +0000 Subject: [PATCH 2/3] fix: add timestamp fields to new entities and resolve TypeORM compilation errors - Add @CreateDateColumn and @UpdateDateColumn to all new entities - Fix PasswordHistoryService TypeORM query syntax - Add missing session management methods to UserService - Resolve all TypeORM compilation errors for authentication hardening Co-Authored-By: Arthur Poon --- .../user/entities/blacklisted-token.entity.ts | 13 ++++++++++++- .../user/entities/password-history.entity.ts | 13 ++++++++++++- src/modules/user/entities/refresh-token.entity.ts | 13 ++++++++++++- src/modules/user/entities/user-session.entity.ts | 13 ++++++++++++- .../user/services/session-management.service.ts | 4 +++- src/modules/user/services/user.service.ts | 14 ++++++++++++++ 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/modules/user/entities/blacklisted-token.entity.ts b/src/modules/user/entities/blacklisted-token.entity.ts index 5b7698a..872bbbb 100644 --- a/src/modules/user/entities/blacklisted-token.entity.ts +++ b/src/modules/user/entities/blacklisted-token.entity.ts @@ -1,6 +1,6 @@ import { AbstractEntity } from 'common/entities'; import { BlacklistedTokenDto } from '../dtos'; -import { Column, Entity } from 'typeorm'; +import { Column, Entity, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity({ name: 'blacklisted_tokens' }) export class BlacklistedTokenEntity extends AbstractEntity { @@ -10,5 +10,16 @@ export class BlacklistedTokenEntity extends AbstractEntity @Column({ type: 'timestamp with time zone' }) expiresAt: Date; + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + createdAt: Date; + + @UpdateDateColumn({ + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date; + dtoClass = BlacklistedTokenDto; } diff --git a/src/modules/user/entities/password-history.entity.ts b/src/modules/user/entities/password-history.entity.ts index 655ba60..a5b7384 100644 --- a/src/modules/user/entities/password-history.entity.ts +++ b/src/modules/user/entities/password-history.entity.ts @@ -1,13 +1,24 @@ import { AbstractEntity } from 'common/entities'; import { PasswordHistoryDto } from '../dtos'; import { UserEntity } from './user.entity'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity({ name: 'password_history' }) export class PasswordHistoryEntity extends AbstractEntity { @Column() passwordHash: string; + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + createdAt: Date; + + @UpdateDateColumn({ + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date; + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn() user: UserEntity; diff --git a/src/modules/user/entities/refresh-token.entity.ts b/src/modules/user/entities/refresh-token.entity.ts index 855477d..72fbdd8 100644 --- a/src/modules/user/entities/refresh-token.entity.ts +++ b/src/modules/user/entities/refresh-token.entity.ts @@ -1,7 +1,7 @@ import { AbstractEntity } from 'common/entities'; import { RefreshTokenDto } from '../dtos'; import { UserEntity } from './user.entity'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity({ name: 'refresh_tokens' }) export class RefreshTokenEntity extends AbstractEntity { @@ -17,6 +17,17 @@ export class RefreshTokenEntity extends AbstractEntity { @Column({ type: 'timestamp with time zone', nullable: true }) revokedAt?: Date; + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + createdAt: Date; + + @UpdateDateColumn({ + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date; + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn() user: UserEntity; diff --git a/src/modules/user/entities/user-session.entity.ts b/src/modules/user/entities/user-session.entity.ts index fe39ac7..dfd5711 100644 --- a/src/modules/user/entities/user-session.entity.ts +++ b/src/modules/user/entities/user-session.entity.ts @@ -1,7 +1,7 @@ import { AbstractEntity } from 'common/entities'; import { UserSessionDto } from '../dtos'; import { UserEntity } from './user.entity'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity({ name: 'user_sessions' }) export class UserSessionEntity extends AbstractEntity { @@ -23,6 +23,17 @@ export class UserSessionEntity extends AbstractEntity { @Column({ type: 'timestamp with time zone' }) expiresAt: Date; + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + createdAt: Date; + + @UpdateDateColumn({ + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date; + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn() user: UserEntity; diff --git a/src/modules/user/services/session-management.service.ts b/src/modules/user/services/session-management.service.ts index e1618a4..4d83163 100644 --- a/src/modules/user/services/session-management.service.ts +++ b/src/modules/user/services/session-management.service.ts @@ -92,7 +92,9 @@ export class SessionManagementService { if (sessions.length > this.MAX_CONCURRENT_SESSIONS) { const sessionsToDelete = sessions.slice(this.MAX_CONCURRENT_SESSIONS); const tokensToDelete = sessionsToDelete.map(s => s.sessionToken); - await this._userSessionRepository.delete({ sessionToken: tokensToDelete }); + for (const token of tokensToDelete) { + await this._userSessionRepository.delete({ sessionToken: token }); + } } } } diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 47e32ec..4ef5c2f 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -14,6 +14,7 @@ import { UserAuthService } from './user-auth.service'; import { UserConfigService } from './user-config.service'; import { CurrencyService } from 'modules/currency/services'; import { MessageEntity } from 'modules/message/entities'; +import { SessionManagementService } from './session-management.service'; @Injectable() export class UserService { @@ -23,6 +24,7 @@ export class UserService { private readonly _userConfigService: UserConfigService, private readonly _billService: BillService, private readonly _currencyService: CurrencyService, + private readonly _sessionManagementService: SessionManagementService, ) {} @Transactional() @@ -142,4 +144,16 @@ export class UserService { return this.getUser({ uuid: user.uuid }); } + + async getUserSessions(user: UserEntity) { + return this._sessionManagementService.getUserSessions(user); + } + + async revokeUserSession(user: UserEntity, sessionToken: string): Promise { + return this._sessionManagementService.revokeSession(sessionToken); + } + + async revokeAllUserSessions(user: UserEntity, exceptSessionToken?: string): Promise { + return this._sessionManagementService.revokeAllUserSessions(user, exceptSessionToken); + } } From dd4882e58a348ce1e3f1dd502d4a1a98dc34684d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:15:31 +0000 Subject: [PATCH 3/3] fix: update module configurations for authentication hardening - Add new repositories and services to UserModule exports and providers - Import RefreshTokenService and TokenBlacklistService in AuthModule - Add TypeORM feature imports for new repositories - Resolve dependency injection errors for authentication services Co-Authored-By: Arthur Poon --- src/modules/auth/index.ts | 10 ++++++++-- src/modules/user/index.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index c247181..f53e356 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,17 +1,23 @@ import { forwardRef, Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from 'modules/auth/controllers'; -import { AuthService } from 'modules/auth/services'; +import { AuthService, RefreshTokenService, TokenBlacklistService } from 'modules/auth/services'; import { JwtResetPasswordStrategy, JwtStrategy } from 'modules/auth/strategies'; import { UserModule } from 'modules/user'; +import { RefreshTokenRepository, BlacklistedTokenRepository } from 'modules/user/repositories'; @Module({ imports: [ forwardRef(() => UserModule), PassportModule.register({ defaultStrategy: 'jwt' }), + TypeOrmModule.forFeature([ + RefreshTokenRepository, + BlacklistedTokenRepository, + ]), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtResetPasswordStrategy], + providers: [AuthService, RefreshTokenService, TokenBlacklistService, JwtStrategy, JwtResetPasswordStrategy], exports: [PassportModule.register({ defaultStrategy: 'jwt' }), AuthService], }) export class AuthModule {} diff --git a/src/modules/user/index.ts b/src/modules/user/index.ts index 9155d74..4b6fab5 100644 --- a/src/modules/user/index.ts +++ b/src/modules/user/index.ts @@ -10,12 +10,18 @@ import { UserAuthRepository, UserConfigRepository, UserRepository, + RefreshTokenRepository, + BlacklistedTokenRepository, + PasswordHistoryRepository, + UserSessionRepository, } from 'modules/user/repositories'; import { UserAuthForgottenPasswordService, UserAuthService, UserConfigService, UserService, + PasswordHistoryService, + SessionManagementService, } from 'modules/user/services'; import { BillModule } from 'modules/bill'; import { CurrencyModule } from 'modules/currency'; @@ -35,6 +41,10 @@ import { UserAuthForgottenPasswordEntity } from './entities'; UserAuthRepository, UserConfigRepository, UserAuthForgottenPasswordRepository, + RefreshTokenRepository, + BlacklistedTokenRepository, + PasswordHistoryRepository, + UserSessionRepository, BillRepository, CurrencyRepository, TransactionRepository, @@ -46,12 +56,16 @@ import { UserAuthForgottenPasswordEntity } from './entities'; UserConfigService, UserService, UserAuthForgottenPasswordService, + PasswordHistoryService, + SessionManagementService, ], providers: [ UserAuthService, UserConfigService, UserService, UserAuthForgottenPasswordService, + PasswordHistoryService, + SessionManagementService, ], }) export class UserModule {}