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: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"@nestjs/schedule": "^0.4.0",
"@nestjs/swagger": "^4.5.12",
"@nestjs/typeorm": "^7.1.0",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"argon2": "^0.43.1",
"bcrypt": "^5.0.0",
"class-transformer": "^0.3.1",
"class-validator": "^0.12.2",
Expand All @@ -50,10 +53,12 @@
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pg": "^8.3.0",
"qrcode": "^1.5.4",
"request-context": "^2.0.0",
"rimraf": "^3.0.2",
"rxjs": "^6.6.0",
"source-map-support": "^0.5.19",
"speakeasy": "^2.0.0",
"swagger-ui-express": "^4.1.4",
"typeorm": "^0.2.25",
"typeorm-transactional-cls-hooked": "^0.1.12",
Expand Down
26 changes: 25 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,31 @@ async function bootstrap(): Promise<void> {

app.enable('trust proxy');
app.use(helmet());
app.use(RateLimit({ windowMs: 15 * 60 * 1000, max: 200 }));
app.use('/bank/auth/login',
RateLimit({
windowMs: 1 * 60 * 1000,
max: 5,
message: 'Too many login attempts, please try again later',
})
);

app.use('/bank/auth/register',
RateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
message: 'Too many registration attempts, please try again later',
})
);

app.use('/bank/auth/password/forget',
RateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
message: 'Too many password reset attempts, please try again later',
})
);

app.use(RateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
app.use(compression());
app.use(morgan('combined'));
app.setGlobalPrefix('bank');
Expand Down
62 changes: 62 additions & 0 deletions src/migrations/1753786297884-SecurityEnhancements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {MigrationInterface, QueryRunner} from "typeorm";

export class SecurityEnhancements1753786297884 implements MigrationInterface {
name = 'SecurityEnhancements1753786297884'

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" character varying NOT NULL, "device_fingerprint" character varying, "ip_address" character varying, "user_agent" text, "is_revoked" boolean NOT NULL DEFAULT false, "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 "UQ_refresh_token" UNIQUE ("token"), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" 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(), "user_id" integer NOT NULL, CONSTRAINT "PK_password_history" PRIMARY KEY ("id"))`);

await queryRunner.query(`CREATE TABLE "user_two_factor" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "secret" character varying NOT NULL, "is_enabled" boolean NOT NULL DEFAULT false, "backup_codes" text, "recovery_codes_used" integer NOT NULL DEFAULT 0, "last_used_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 "REL_user_two_factor_user" UNIQUE ("user_id"), CONSTRAINT "PK_user_two_factor" PRIMARY KEY ("id"))`);

await queryRunner.query(`CREATE TABLE "device_sessions" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "device_fingerprint" character varying NOT NULL, "device_name" character varying, "ip_address" character varying, "user_agent" text, "last_activity" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "is_active" boolean NOT NULL DEFAULT true, "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_device_sessions" PRIMARY KEY ("id"))`);

await queryRunner.query(`CREATE TABLE "account_lockouts" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "failed_attempts" integer NOT NULL DEFAULT 0, "locked_until" TIMESTAMP WITH TIME ZONE, "last_failed_attempt" TIMESTAMP WITH TIME ZONE, "ip_address" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "user_id" integer NOT NULL, CONSTRAINT "REL_account_lockouts_user" UNIQUE ("user_id"), CONSTRAINT "PK_account_lockouts" PRIMARY KEY ("id"))`);

await queryRunner.query(`CREATE TYPE "security_audit_logs_event_type_enum" AS ENUM('LOGIN_SUCCESS', 'LOGIN_FAILED', 'LOGOUT', 'PASSWORD_CHANGED', 'TWO_FACTOR_ENABLED', 'TWO_FACTOR_DISABLED', 'ACCOUNT_LOCKED', 'ACCOUNT_UNLOCKED', 'TOKEN_REFRESHED', 'SUSPICIOUS_ACTIVITY')`);
await queryRunner.query(`CREATE TABLE "security_audit_logs" ("id" SERIAL NOT NULL, "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "event_type" "security_audit_logs_event_type_enum" NOT NULL, "description" text, "ip_address" character varying, "user_agent" text, "metadata" jsonb, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "user_id" integer, CONSTRAINT "PK_security_audit_logs" PRIMARY KEY ("id"))`);

await queryRunner.query(`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_refresh_tokens_user" 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" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user_two_factor" ADD CONSTRAINT "FK_user_two_factor_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "device_sessions" ADD CONSTRAINT "FK_device_sessions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "account_lockouts" ADD CONSTRAINT "FK_account_lockouts_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "security_audit_logs" ADD CONSTRAINT "FK_security_audit_logs_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);

await queryRunner.query(`CREATE INDEX "IDX_refresh_tokens_user_id" ON "refresh_tokens" ("user_id")`);
await queryRunner.query(`CREATE INDEX "IDX_refresh_tokens_expires_at" ON "refresh_tokens" ("expires_at")`);
await queryRunner.query(`CREATE INDEX "IDX_password_history_user_id" ON "password_history" ("user_id")`);
await queryRunner.query(`CREATE INDEX "IDX_device_sessions_user_id" ON "device_sessions" ("user_id")`);
await queryRunner.query(`CREATE INDEX "IDX_device_sessions_fingerprint" ON "device_sessions" ("device_fingerprint")`);
await queryRunner.query(`CREATE INDEX "IDX_security_audit_logs_user_id" ON "security_audit_logs" ("user_id")`);
await queryRunner.query(`CREATE INDEX "IDX_security_audit_logs_event_type" ON "security_audit_logs" ("event_type")`);
await queryRunner.query(`CREATE INDEX "IDX_security_audit_logs_created_at" ON "security_audit_logs" ("created_at")`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_security_audit_logs_created_at"`);
await queryRunner.query(`DROP INDEX "IDX_security_audit_logs_event_type"`);
await queryRunner.query(`DROP INDEX "IDX_security_audit_logs_user_id"`);
await queryRunner.query(`DROP INDEX "IDX_device_sessions_fingerprint"`);
await queryRunner.query(`DROP INDEX "IDX_device_sessions_user_id"`);
await queryRunner.query(`DROP INDEX "IDX_password_history_user_id"`);
await queryRunner.query(`DROP INDEX "IDX_refresh_tokens_expires_at"`);
await queryRunner.query(`DROP INDEX "IDX_refresh_tokens_user_id"`);

await queryRunner.query(`ALTER TABLE "security_audit_logs" DROP CONSTRAINT "FK_security_audit_logs_user"`);
await queryRunner.query(`ALTER TABLE "account_lockouts" DROP CONSTRAINT "FK_account_lockouts_user"`);
await queryRunner.query(`ALTER TABLE "device_sessions" DROP CONSTRAINT "FK_device_sessions_user"`);
await queryRunner.query(`ALTER TABLE "user_two_factor" DROP CONSTRAINT "FK_user_two_factor_user"`);
await queryRunner.query(`ALTER TABLE "password_history" DROP CONSTRAINT "FK_password_history_user"`);
await queryRunner.query(`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_refresh_tokens_user"`);

await queryRunner.query(`DROP TABLE "security_audit_logs"`);
await queryRunner.query(`DROP TYPE "security_audit_logs_event_type_enum"`);
await queryRunner.query(`DROP TABLE "account_lockouts"`);
await queryRunner.query(`DROP TABLE "device_sessions"`);
await queryRunner.query(`DROP TABLE "user_two_factor"`);
await queryRunner.query(`DROP TABLE "password_history"`);
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
}
}
2 changes: 2 additions & 0 deletions src/modules/app/services/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export class AppService implements OnModuleInit {
lastName: 'Application',
email: rootEmail,
password: rootPassword,
confirmPassword: rootPassword,
currency: uuid,
});

Expand Down Expand Up @@ -116,6 +117,7 @@ export class AppService implements OnModuleInit {
lastName: authorLastName,
email: authorEmail,
password: authorPassword,
confirmPassword: authorPassword,
currency: uuid,
});

Expand Down
207 changes: 202 additions & 5 deletions src/modules/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import {
HttpStatus,
Patch,
Post,
Get,
Delete,
Param,
Req,
UseGuards,
UseInterceptors,
Headers,
Ip,
} from '@nestjs/common';
import {
ApiBearerAuth,
Expand All @@ -26,11 +31,17 @@ import {
UserRegisterDto,
UserResetPasswordDto,
} from 'modules/auth/dtos';
import { RefreshTokenDto } from '../dtos/refresh-token.dto';
import { TwoFactorSetupDto, TwoFactorVerifyDto } from '../dtos/two-factor-setup.dto';
import { ChangePasswordDto } from '../dtos/change-password.dto';
import { AuthService } from 'modules/auth/services';
import { AuthSecurityService } from '../services/auth-security.service';
import { TwoFactorService } from '../services/two-factor.service';
import { UserDto } from 'modules/user/dtos';
import { UserEntity } from 'modules/user/entities';
import { UserAuthService, UserService } from 'modules/user/services';
import { Transactional } from 'typeorm-transactional-cls-hooked';
import * as crypto from 'crypto';

@Controller('Auth')
@ApiTags('Auth')
Expand All @@ -39,6 +50,8 @@ export class AuthController {
private readonly _userService: UserService,
private readonly _userAuthService: UserAuthService,
private readonly _authService: AuthService,
private readonly _authSecurityService: AuthSecurityService,
private readonly _twoFactorService: TwoFactorService,
) {}

@Post('login')
Expand All @@ -49,12 +62,42 @@ export class AuthController {
description: 'User info with access token',
})
async userLogin(
@Body() userLoginDto: UserLoginDto,
): Promise<LoginPayloadDto> {
const user = await this._authService.validateUser(userLoginDto);
const token = await this._authService.createToken(user);
@Body() userLoginDto: UserLoginDto & { twoFactorToken?: string },
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string,
@Headers('x-device-fingerprint') deviceFingerprint: string,
): Promise<any> {
try {
const user = await this._authService.validateUser(userLoginDto);

const enhancedResult = await this._authSecurityService.enhancedLogin(
user,
ipAddress,
userAgent || 'Unknown',
deviceFingerprint || this.generateDeviceFingerprint(userAgent, ipAddress),
userLoginDto.twoFactorToken
);

return new LoginPayloadDto(user.toDto(), token);
if (enhancedResult.requiresTwoFactor) {
return enhancedResult;
}

const token = await this._authService.createToken(user);

return {
...new LoginPayloadDto(user.toDto(), token),
refreshToken: enhancedResult.refreshToken,
expiresIn: enhancedResult.expiresIn,
};
} catch (error) {
if (error.message !== 'Invalid two-factor authentication code') {
const user = await this._userAuthService.findUserAuth({ pinCode: userLoginDto.pinCode });
if (user) {
await this._authSecurityService.recordFailedLogin(user.userAuth.id, ipAddress);
}
}
throw error;
}
}

@Post('register')
Expand Down Expand Up @@ -111,4 +154,158 @@ export class AuthController {
) {
return this._authService.handleResetPassword(password, user);
}

@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'Token refreshed successfully',
})
async refreshToken(
@Body() refreshTokenDto: RefreshTokenDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string,
): Promise<any> {
return this._authSecurityService.refreshTokens(
refreshTokenDto.refreshToken,
refreshTokenDto.deviceFingerprint,
ipAddress,
userAgent || 'Unknown'
);
}

@Post('2fa/setup')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'Two-factor authentication setup initiated',
})
async setupTwoFactor(@AuthUser() user: UserEntity): Promise<any> {
return this._twoFactorService.generateSecret(user.userAuth.id);
}

@Post('2fa/enable')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'Two-factor authentication enabled',
})
async enableTwoFactor(
@AuthUser() user: UserEntity,
@Body() twoFactorSetupDto: TwoFactorSetupDto,
): Promise<any> {
const success = await this._twoFactorService.enableTwoFactor(user.userAuth.id, twoFactorSetupDto.token);
if (!success) {
throw new Error('Invalid two-factor authentication code');
}
return { message: 'Two-factor authentication enabled successfully' };
}

@Post('2fa/disable')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'Two-factor authentication disabled',
})
async disableTwoFactor(
@AuthUser() user: UserEntity,
@Body() twoFactorVerifyDto: TwoFactorVerifyDto,
): Promise<any> {
const success = await this._twoFactorService.disableTwoFactor(user.userAuth.id, twoFactorVerifyDto.token);
if (!success) {
throw new Error('Invalid two-factor authentication code');
}
return { message: 'Two-factor authentication disabled successfully' };
}

@Post('2fa/backup-codes')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'New backup codes generated',
})
async generateBackupCodes(@AuthUser() user: UserEntity): Promise<any> {
const backupCodes = await this._twoFactorService.generateNewBackupCodes(user.userAuth.id);
return { backupCodes };
}

@Get('2fa/status')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'Two-factor authentication status',
})
async getTwoFactorStatus(@AuthUser() user: UserEntity): Promise<any> {
return this._twoFactorService.getTwoFactorStatus(user.userAuth.id);
}

@Post('logout-all')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse({
description: 'Successfully logged out from all devices',
})
async logoutAll(@AuthUser() user: UserEntity): Promise<void> {
await this._authSecurityService.logoutAll(user.userAuth.id);
}

@Get('sessions')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({
status: HttpStatus.OK,
description: 'User device sessions',
})
async getUserSessions(@AuthUser() user: UserEntity): Promise<any> {
return this._authSecurityService.getUserDeviceSessions(user.userAuth.id);
}

@Delete('sessions/:sessionId')
@UseGuards(AuthGuard, RolesGuard)
@UseInterceptors(AuthUserInterceptor)
@ApiBearerAuth()
@Roles(RoleType.USER, RoleType.ADMIN, RoleType.ROOT)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse({
description: 'Session revoked successfully',
})
async revokeSession(
@AuthUser() user: UserEntity,
@Param('sessionId') sessionId: number,
): Promise<void> {
await this._authSecurityService.revokeDeviceSession(user.userAuth.id, sessionId);
}

private generateDeviceFingerprint(userAgent: string, ipAddress: string): string {
return crypto.createHash('sha256')
.update(`${userAgent}-${ipAddress}`)
.digest('hex')
.substring(0, 32);
}
}
Loading