Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/decorators/validators.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
},
},
});
Expand Down
29 changes: 29 additions & 0 deletions src/guards/enhanced-jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean>;
}

private _extractTokenFromHeader(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
}
}
22 changes: 22 additions & 0 deletions src/middlewares/token-blacklist.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
38 changes: 38 additions & 0 deletions src/migrations/1753739900000-auth-hardening.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AuthHardening1753739900000 implements MigrationInterface {
name = 'AuthHardening1753739900000'

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
85 changes: 82 additions & 3 deletions src/modules/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Req,
Expand All @@ -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';
Expand All @@ -50,9 +55,16 @@ export class AuthController {
})
async userLogin(
@Body() userLoginDto: UserLoginDto,
@Req() req: any,
): Promise<LoginPayloadDto> {
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);
}
Expand Down Expand Up @@ -80,8 +92,9 @@ export class AuthController {
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
async userLogout(@AuthUser() user: UserEntity): Promise<void> {
await this._userAuthService.updateLastLogoutDate(user.userAuth);
async userLogout(@AuthUser() user: UserEntity, @Req() req: any): Promise<void> {
const token = req.headers.authorization?.substring(7);
await this._authService.logout(user, token);
}

@Post('password/forget')
Expand Down Expand Up @@ -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<EnhancedTokenPayloadDto> {
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<void> {
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<void> {
const currentSessionToken = req.headers.authorization?.substring(7);
return this._userService.revokeAllUserSessions(user, currentSessionToken);
}
}
31 changes: 31 additions & 0 deletions src/modules/auth/crons/auth-cleanup.cron.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
22 changes: 22 additions & 0 deletions src/modules/auth/dtos/enhanced-token-payload.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions src/modules/auth/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
9 changes: 9 additions & 0 deletions src/modules/auth/dtos/refresh-token-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class RefreshTokenRequestDto {
@IsString()
@IsNotEmpty()
@ApiProperty()
readonly refreshToken: string;
}
10 changes: 8 additions & 2 deletions src/modules/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading