diff --git a/package.json b/package.json index 0c6fd32..025cba3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/main.ts b/src/main.ts index 7198cf4..b70fc8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,31 @@ async function bootstrap(): Promise { 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'); diff --git a/src/migrations/1753786297884-SecurityEnhancements.ts b/src/migrations/1753786297884-SecurityEnhancements.ts new file mode 100644 index 0000000..85d354f --- /dev/null +++ b/src/migrations/1753786297884-SecurityEnhancements.ts @@ -0,0 +1,62 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class SecurityEnhancements1753786297884 implements MigrationInterface { + name = 'SecurityEnhancements1753786297884' + + 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" 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 { + 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"`); + } +} diff --git a/src/modules/app/services/app.service.ts b/src/modules/app/services/app.service.ts index dc31a49..0b1bfa4 100644 --- a/src/modules/app/services/app.service.ts +++ b/src/modules/app/services/app.service.ts @@ -85,6 +85,7 @@ export class AppService implements OnModuleInit { lastName: 'Application', email: rootEmail, password: rootPassword, + confirmPassword: rootPassword, currency: uuid, }); @@ -116,6 +117,7 @@ export class AppService implements OnModuleInit { lastName: authorLastName, email: authorEmail, password: authorPassword, + confirmPassword: authorPassword, currency: uuid, }); diff --git a/src/modules/auth/controllers/auth.controller.ts b/src/modules/auth/controllers/auth.controller.ts index 7160b79..c97ef78 100644 --- a/src/modules/auth/controllers/auth.controller.ts +++ b/src/modules/auth/controllers/auth.controller.ts @@ -5,9 +5,14 @@ import { HttpStatus, Patch, Post, + Get, + Delete, + Param, Req, UseGuards, UseInterceptors, + Headers, + Ip, } from '@nestjs/common'; import { ApiBearerAuth, @@ -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') @@ -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') @@ -49,12 +62,42 @@ export class AuthController { description: 'User info with access token', }) async userLogin( - @Body() userLoginDto: UserLoginDto, - ): Promise { - 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 { + 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') @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } } diff --git a/src/modules/auth/dtos/change-password.dto.ts b/src/modules/auth/dtos/change-password.dto.ts new file mode 100644 index 0000000..d25b69a --- /dev/null +++ b/src/modules/auth/dtos/change-password.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength, Matches, Validate } from 'class-validator'; +import { IsPasswordStrong } from '../validators/password-strength.validator'; + +export class ChangePasswordDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly currentPassword: string; + + @IsString() + @MinLength(8) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,}$/, { + message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }) + @Validate(IsPasswordStrong) + @ApiProperty({ + minLength: 8, + description: 'Password must be at least 8 characters long and contain uppercase, lowercase, number, and special character' + }) + readonly newPassword: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly confirmPassword: string; +} diff --git a/src/modules/auth/dtos/refresh-token.dto.ts b/src/modules/auth/dtos/refresh-token.dto.ts new file mode 100644 index 0000000..ad945ee --- /dev/null +++ b/src/modules/auth/dtos/refresh-token.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly refreshToken: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly deviceFingerprint: string; +} diff --git a/src/modules/auth/dtos/two-factor-setup.dto.ts b/src/modules/auth/dtos/two-factor-setup.dto.ts new file mode 100644 index 0000000..fc7cec6 --- /dev/null +++ b/src/modules/auth/dtos/two-factor-setup.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class TwoFactorSetupDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly token: string; +} + +export class TwoFactorVerifyDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly token: string; +} diff --git a/src/modules/auth/dtos/user-register.dto.ts b/src/modules/auth/dtos/user-register.dto.ts index 8dd4055..af6f1a0 100644 --- a/src/modules/auth/dtos/user-register.dto.ts +++ b/src/modules/auth/dtos/user-register.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString, MinLength, Matches, Validate } from 'class-validator'; +import { IsPasswordStrong } from '../validators/password-strength.validator'; export class UserRegisterDto { @IsString() @@ -19,10 +20,22 @@ export class UserRegisterDto { readonly email: string; @IsString() - @MinLength(6) - @ApiProperty({ minLength: 6 }) + @MinLength(8) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,}$/, { + message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }) + @Validate(IsPasswordStrong) + @ApiProperty({ + minLength: 8, + description: 'Password must be at least 8 characters long and contain uppercase, lowercase, number, and special character' + }) readonly password: string; + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly confirmPassword: string; + @IsString() @IsNotEmpty() @ApiProperty() diff --git a/src/modules/auth/entities/account-lockout.entity.ts b/src/modules/auth/entities/account-lockout.entity.ts new file mode 100644 index 0000000..9892d95 --- /dev/null +++ b/src/modules/auth/entities/account-lockout.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Entity('account_lockouts') +export class AccountLockoutEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column({ name: 'failed_attempts', default: 0 }) + failedAttempts: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil: Date; + + @Column({ name: 'last_failed_attempt', type: 'timestamptz', nullable: true }) + lastFailedAttempt: Date; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'user_id' }) + userId: number; + + @OneToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/entities/device-session.entity.ts b/src/modules/auth/entities/device-session.entity.ts new file mode 100644 index 0000000..499def6 --- /dev/null +++ b/src/modules/auth/entities/device-session.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Entity('device_sessions') +export class DeviceSessionEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column({ name: 'device_fingerprint' }) + deviceFingerprint: string; + + @Column({ name: 'device_name', nullable: true }) + deviceName: string; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'last_activity', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastActivity: Date; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'user_id' }) + userId: number; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/entities/password-history.entity.ts b/src/modules/auth/entities/password-history.entity.ts new file mode 100644 index 0000000..e0ebf91 --- /dev/null +++ b/src/modules/auth/entities/password-history.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Entity('password_history') +export class PasswordHistoryEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column({ name: 'password_hash' }) + passwordHash: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'user_id' }) + userId: number; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/entities/refresh-token.entity.ts b/src/modules/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..99f7c03 --- /dev/null +++ b/src/modules/auth/entities/refresh-token.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Entity('refresh_tokens') +export class RefreshTokenEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column({ unique: true }) + token: string; + + @Column({ name: 'device_fingerprint', nullable: true }) + deviceFingerprint: string; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'is_revoked', default: false }) + isRevoked: boolean; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'user_id' }) + userId: number; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/entities/security-audit-log.entity.ts b/src/modules/auth/entities/security-audit-log.entity.ts new file mode 100644 index 0000000..3874b74 --- /dev/null +++ b/src/modules/auth/entities/security-audit-log.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +export enum SecurityEventType { + LOGIN_SUCCESS = 'LOGIN_SUCCESS', + LOGIN_FAILED = 'LOGIN_FAILED', + LOGOUT = 'LOGOUT', + PASSWORD_CHANGED = 'PASSWORD_CHANGED', + TWO_FACTOR_ENABLED = 'TWO_FACTOR_ENABLED', + TWO_FACTOR_DISABLED = 'TWO_FACTOR_DISABLED', + ACCOUNT_LOCKED = 'ACCOUNT_LOCKED', + ACCOUNT_UNLOCKED = 'ACCOUNT_UNLOCKED', + TOKEN_REFRESHED = 'TOKEN_REFRESHED', + SUSPICIOUS_ACTIVITY = 'SUSPICIOUS_ACTIVITY', +} + +@Entity('security_audit_logs') +export class SecurityAuditLogEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column({ + name: 'event_type', + type: 'enum', + enum: SecurityEventType, + }) + eventType: SecurityEventType; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: any; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'user_id', nullable: true }) + userId: number; + + @ManyToOne(() => UserEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/entities/user-two-factor.entity.ts b/src/modules/auth/entities/user-two-factor.entity.ts new file mode 100644 index 0000000..0e6a8a6 --- /dev/null +++ b/src/modules/auth/entities/user-two-factor.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Entity('user_two_factor') +export class UserTwoFactorEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'uuid', generated: 'uuid' }) + uuid: string; + + @Column() + secret: string; + + @Column({ name: 'is_enabled', default: false }) + isEnabled: boolean; + + @Column({ name: 'backup_codes', type: 'text', nullable: true }) + backupCodes: string; + + @Column({ name: 'recovery_codes_used', default: 0 }) + recoveryCodesUsed: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'user_id' }) + userId: number; + + @OneToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index c247181..4e1babe 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,17 +1,56 @@ 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 { AuthSecurityService } from './services/auth-security.service'; +import { RefreshTokenService } from './services/refresh-token.service'; +import { TwoFactorService } from './services/two-factor.service'; +import { AccountLockoutService } from './services/account-lockout.service'; +import { PasswordHistoryService } from './services/password-history.service'; import { JwtResetPasswordStrategy, JwtStrategy } from 'modules/auth/strategies'; import { UserModule } from 'modules/user'; +import { RefreshTokenEntity } from './entities/refresh-token.entity'; +import { DeviceSessionEntity } from './entities/device-session.entity'; +import { SecurityAuditLogEntity } from './entities/security-audit-log.entity'; +import { UserTwoFactorEntity } from './entities/user-two-factor.entity'; +import { AccountLockoutEntity } from './entities/account-lockout.entity'; +import { PasswordHistoryEntity } from './entities/password-history.entity'; +import { UserEntity } from '../user/entities/user.entity'; @Module({ imports: [ forwardRef(() => UserModule), + TypeOrmModule.forFeature([ + RefreshTokenEntity, + DeviceSessionEntity, + SecurityAuditLogEntity, + UserTwoFactorEntity, + AccountLockoutEntity, + PasswordHistoryEntity, + UserEntity, + ]), PassportModule.register({ defaultStrategy: 'jwt' }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtResetPasswordStrategy], - exports: [PassportModule.register({ defaultStrategy: 'jwt' }), AuthService], + providers: [ + AuthService, + AuthSecurityService, + RefreshTokenService, + TwoFactorService, + AccountLockoutService, + PasswordHistoryService, + JwtStrategy, + JwtResetPasswordStrategy, + ], + exports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + AuthService, + AuthSecurityService, + RefreshTokenService, + TwoFactorService, + AccountLockoutService, + PasswordHistoryService, + ], }) export class AuthModule {} diff --git a/src/modules/auth/services/account-lockout.service.ts b/src/modules/auth/services/account-lockout.service.ts new file mode 100644 index 0000000..69d89ec --- /dev/null +++ b/src/modules/auth/services/account-lockout.service.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AccountLockoutEntity } from '../entities/account-lockout.entity'; +import { SecurityAuditLogEntity, SecurityEventType } from '../entities/security-audit-log.entity'; + +@Injectable() +export class AccountLockoutService { + private readonly maxFailedAttempts = 5; + private readonly lockoutDurations = [5, 15, 60, 1440]; + + constructor( + @InjectRepository(AccountLockoutEntity) + private readonly lockoutRepository: Repository, + @InjectRepository(SecurityAuditLogEntity) + private readonly auditLogRepository: Repository, + ) {} + + async recordFailedAttempt(userId: number, ipAddress: string): Promise { + let lockout = await this.lockoutRepository.findOne({ where: { userId } }); + + if (!lockout) { + lockout = await this.lockoutRepository.save({ + userId, + failedAttempts: 1, + lastFailedAttempt: new Date(), + ipAddress, + }); + } else { + const now = new Date(); + const timeSinceLastAttempt = now.getTime() - (lockout.lastFailedAttempt?.getTime() || 0); + + if (timeSinceLastAttempt > 15 * 60 * 1000) { + lockout.failedAttempts = 1; + } else { + lockout.failedAttempts += 1; + } + + lockout.lastFailedAttempt = now; + lockout.ipAddress = ipAddress; + + if (lockout.failedAttempts >= this.maxFailedAttempts) { + const lockoutLevel = Math.min( + Math.floor((lockout.failedAttempts - this.maxFailedAttempts) / this.maxFailedAttempts), + this.lockoutDurations.length - 1 + ); + + const lockoutMinutes = this.lockoutDurations[lockoutLevel]; + lockout.lockedUntil = new Date(now.getTime() + lockoutMinutes * 60 * 1000); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.ACCOUNT_LOCKED; + auditLog.description = `Account locked for ${lockoutMinutes} minutes after ${lockout.failedAttempts} failed attempts`; + auditLog.ipAddress = ipAddress; + auditLog.userId = userId; + auditLog.metadata = { + failedAttempts: lockout.failedAttempts, + lockoutMinutes, + lockoutLevel, + }; + await this.auditLogRepository.save(auditLog); + } + + await this.lockoutRepository.save(lockout); + } + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.LOGIN_FAILED; + auditLog.description = 'Failed login attempt'; + auditLog.ipAddress = ipAddress; + auditLog.userId = userId; + auditLog.metadata = { + failedAttempts: lockout.failedAttempts, + isLocked: lockout.lockedUntil && lockout.lockedUntil > new Date(), + }; + await this.auditLogRepository.save(auditLog); + } + + async isAccountLocked(userId: number): Promise<{ isLocked: boolean; lockedUntil?: Date; failedAttempts?: number }> { + const lockout = await this.lockoutRepository.findOne({ where: { userId } }); + + if (!lockout || !lockout.lockedUntil) { + return { isLocked: false, failedAttempts: lockout?.failedAttempts || 0 }; + } + + const now = new Date(); + + if (lockout.lockedUntil > now) { + return { + isLocked: true, + lockedUntil: lockout.lockedUntil, + failedAttempts: lockout.failedAttempts, + }; + } + + await this.lockoutRepository.update( + { userId }, + { lockedUntil: null, failedAttempts: 0 } + ); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.ACCOUNT_UNLOCKED; + auditLog.description = 'Account automatically unlocked after lockout period expired'; + auditLog.userId = userId; + auditLog.metadata = {}; + await this.auditLogRepository.save(auditLog); + + return { isLocked: false, failedAttempts: 0 }; + } + + async resetFailedAttempts(userId: number): Promise { + await this.lockoutRepository.update( + { userId }, + { failedAttempts: 0, lockedUntil: null } + ); + } + + async unlockAccount(userId: number, adminUserId?: number): Promise { + await this.lockoutRepository.update( + { userId }, + { failedAttempts: 0, lockedUntil: null } + ); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.ACCOUNT_UNLOCKED; + auditLog.description = adminUserId ? 'Account manually unlocked by admin' : 'Account unlocked'; + auditLog.userId = userId; + auditLog.metadata = { adminUserId }; + await this.auditLogRepository.save(auditLog); + } + + async getFailedAttempts(userId: number): Promise { + const lockout = await this.lockoutRepository.findOne({ where: { userId } }); + return lockout?.failedAttempts || 0; + } +} diff --git a/src/modules/auth/services/auth-security.service.ts b/src/modules/auth/services/auth-security.service.ts new file mode 100644 index 0000000..76f6d78 --- /dev/null +++ b/src/modules/auth/services/auth-security.service.ts @@ -0,0 +1,175 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { RefreshTokenEntity } from '../entities/refresh-token.entity'; +import { DeviceSessionEntity } from '../entities/device-session.entity'; +import { SecurityAuditLogEntity, SecurityEventType } from '../entities/security-audit-log.entity'; +import { UserTwoFactorEntity } from '../entities/user-two-factor.entity'; +import { AccountLockoutEntity } from '../entities/account-lockout.entity'; +import { PasswordHistoryEntity } from '../entities/password-history.entity'; +import { UserEntity } from '../../user/entities/user.entity'; +import { UtilsService } from '../../../utils/services/utils.service'; +import { RefreshTokenService } from './refresh-token.service'; +import { TwoFactorService } from './two-factor.service'; +import { AccountLockoutService } from './account-lockout.service'; +import { PasswordHistoryService } from './password-history.service'; + +@Injectable() +export class AuthSecurityService { + constructor( + @InjectRepository(RefreshTokenEntity) + private readonly refreshTokenRepository: Repository, + @InjectRepository(DeviceSessionEntity) + private readonly deviceSessionRepository: Repository, + @InjectRepository(SecurityAuditLogEntity) + private readonly auditLogRepository: Repository, + @InjectRepository(UserTwoFactorEntity) + private readonly twoFactorRepository: Repository, + @InjectRepository(AccountLockoutEntity) + private readonly lockoutRepository: Repository, + @InjectRepository(PasswordHistoryEntity) + private readonly passwordHistoryRepository: Repository, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly refreshTokenService: RefreshTokenService, + private readonly twoFactorService: TwoFactorService, + private readonly accountLockoutService: AccountLockoutService, + private readonly passwordHistoryService: PasswordHistoryService, + ) {} + + async enhancedLogin( + user: UserEntity, + ipAddress: string, + userAgent: string, + deviceFingerprint: string, + twoFactorToken?: string + ): Promise { + const lockoutStatus = await this.accountLockoutService.isAccountLocked(user.userAuth.id); + if (lockoutStatus.isLocked) { + throw new UnauthorizedException(`Account is locked until ${lockoutStatus.lockedUntil}`); + } + + const isTwoFactorEnabled = await this.twoFactorService.isTwoFactorEnabled(user.userAuth.id); + + if (isTwoFactorEnabled) { + if (!twoFactorToken) { + return { + requiresTwoFactor: true, + message: 'Two-factor authentication required' + }; + } + + const isValidTwoFactor = await this.twoFactorService.verifyToken(user.userAuth.id, twoFactorToken) || + await this.twoFactorService.verifyBackupCode(user.userAuth.id, twoFactorToken); + + if (!isValidTwoFactor) { + await this.auditLogRepository.save({ + eventType: SecurityEventType.LOGIN_FAILED, + description: 'Invalid two-factor authentication code', + ipAddress, + userAgent, + userId: user.userAuth.id, + }); + throw new UnauthorizedException('Invalid two-factor authentication code'); + } + } + + const payload = { uuid: user.uuid, role: user.userAuth?.role }; + const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.userAuth.id, + deviceFingerprint, + ipAddress, + userAgent + ); + + await this.accountLockoutService.resetFailedAttempts(user.userAuth.id); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.LOGIN_SUCCESS; + auditLog.description = 'User logged in successfully'; + auditLog.ipAddress = ipAddress; + auditLog.userAgent = userAgent; + auditLog.userId = user.userAuth.id; + auditLog.metadata = { + deviceFingerprint, + twoFactorUsed: isTwoFactorEnabled + }; + await this.auditLogRepository.save(auditLog); + + return { + accessToken, + refreshToken, + expiresIn: 900, + user: { + id: user.userAuth.id, + uuid: user.uuid, + requiresTwoFactor: false + } + }; + } + + async recordFailedLogin(userId: number, ipAddress: string): Promise { + await this.accountLockoutService.recordFailedAttempt(userId, ipAddress); + } + + async refreshTokens( + refreshToken: string, + deviceFingerprint: string, + ipAddress: string, + userAgent: string + ): Promise { + const tokens = await this.refreshTokenService.rotateRefreshToken( + refreshToken, + deviceFingerprint, + ipAddress, + userAgent + ); + + if (!tokens) { + throw new UnauthorizedException('Invalid refresh token'); + } + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: 900, + }; + } + + async logout(userId: number, refreshToken?: string): Promise { + if (refreshToken) { + await this.refreshTokenService.revokeToken(refreshToken); + } + + await this.auditLogRepository.save({ + eventType: SecurityEventType.LOGOUT, + description: 'User logged out', + userId, + }); + } + + async logoutAll(userId: number): Promise { + await this.refreshTokenService.revokeAllUserTokens(userId); + + await this.auditLogRepository.save({ + eventType: SecurityEventType.LOGOUT, + description: 'User logged out from all devices', + userId, + }); + } + + async isTokenBlacklisted(token: string): Promise { + return this.refreshTokenService.isTokenBlacklisted(token); + } + + async getUserDeviceSessions(userId: number): Promise { + return this.refreshTokenService.getUserDeviceSessions(userId); + } + + async revokeDeviceSession(userId: number, sessionId: number): Promise { + await this.refreshTokenService.revokeDeviceSession(userId, sessionId); + } +} diff --git a/src/modules/auth/services/password-history.service.ts b/src/modules/auth/services/password-history.service.ts new file mode 100644 index 0000000..ab19e66 --- /dev/null +++ b/src/modules/auth/services/password-history.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PasswordHistoryEntity } from '../entities/password-history.entity'; +import { UtilsService } from '../../../utils/services/utils.service'; + +@Injectable() +export class PasswordHistoryService { + private readonly maxPasswordHistory = 12; + + constructor( + @InjectRepository(PasswordHistoryEntity) + private readonly passwordHistoryRepository: Repository, + ) {} + + async addPasswordToHistory(userId: number, passwordHash: string): Promise { + await this.passwordHistoryRepository.save({ + userId, + passwordHash, + }); + + const historyCount = await this.passwordHistoryRepository.count({ + where: { userId }, + }); + + if (historyCount > this.maxPasswordHistory) { + const oldestPasswords = await this.passwordHistoryRepository.find({ + where: { userId }, + order: { createdAt: 'ASC' }, + take: historyCount - this.maxPasswordHistory, + }); + + const idsToDelete = oldestPasswords.map(p => p.id); + await this.passwordHistoryRepository.delete(idsToDelete); + } + } + + async isPasswordReused(userId: number, newPassword: string): Promise { + const passwordHistory = await this.passwordHistoryRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: this.maxPasswordHistory, + }); + + for (const historyEntry of passwordHistory) { + const isMatch = await UtilsService.validateHash(newPassword, historyEntry.passwordHash); + if (isMatch) { + return true; + } + } + + return false; + } + + async getPasswordHistoryCount(userId: number): Promise { + return this.passwordHistoryRepository.count({ + where: { userId }, + }); + } +} 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..4594aae --- /dev/null +++ b/src/modules/auth/services/refresh-token.service.ts @@ -0,0 +1,217 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as crypto from 'crypto'; +import { RefreshTokenEntity } from '../entities/refresh-token.entity'; +import { DeviceSessionEntity } from '../entities/device-session.entity'; +import { SecurityAuditLogEntity, SecurityEventType } from '../entities/security-audit-log.entity'; + +@Injectable() +export class RefreshTokenService { + constructor( + @InjectRepository(RefreshTokenEntity) + private readonly refreshTokenRepository: Repository, + @InjectRepository(DeviceSessionEntity) + private readonly deviceSessionRepository: Repository, + @InjectRepository(SecurityAuditLogEntity) + private readonly auditLogRepository: Repository, + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + ) {} + + async generateRefreshToken( + userId: number, + deviceFingerprint: string, + ipAddress: string, + userAgent: string, + ): Promise { + const token = crypto.randomBytes(64).toString('hex'); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await this.refreshTokenRepository.save({ + token, + userId, + deviceFingerprint, + ipAddress, + userAgent, + expiresAt, + }); + + await this.updateDeviceSession(userId, deviceFingerprint, ipAddress, userAgent); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.TOKEN_REFRESHED; + auditLog.description = 'Refresh token generated'; + auditLog.ipAddress = ipAddress; + auditLog.userAgent = userAgent; + auditLog.userId = userId; + auditLog.metadata = { deviceFingerprint }; + await this.auditLogRepository.save(auditLog); + + return token; + } + + async rotateRefreshToken( + oldToken: string, + deviceFingerprint: string, + ipAddress: string, + userAgent: string, + ): Promise<{ accessToken: string; refreshToken: string } | null> { + const existingToken = await this.refreshTokenRepository.findOne({ + where: { token: oldToken, isRevoked: false }, + }); + + if (!existingToken || existingToken.expiresAt < new Date()) { + return null; + } + + await this.refreshTokenRepository.update( + { token: oldToken }, + { isRevoked: true } + ); + + const newRefreshToken = await this.generateRefreshToken( + existingToken.userId, + deviceFingerprint, + ipAddress, + userAgent, + ); + + const payload = { sub: existingToken.userId }; + const accessToken = this.jwtService.sign(payload, { + expiresIn: '15m', + }); + + return { + accessToken, + refreshToken: newRefreshToken, + }; + } + + async revokeToken(token: string): Promise { + await this.refreshTokenRepository.update( + { token }, + { isRevoked: true } + ); + } + + async revokeAllUserTokens(userId: number): Promise { + await this.refreshTokenRepository.update( + { userId, isRevoked: false }, + { isRevoked: true } + ); + + await this.deviceSessionRepository.update( + { userId, isActive: true }, + { isActive: false } + ); + } + + async isTokenBlacklisted(token: string): Promise { + const refreshToken = await this.refreshTokenRepository.findOne({ + where: { token }, + }); + + return refreshToken?.isRevoked || false; + } + + async cleanupExpiredTokens(): Promise { + await this.refreshTokenRepository.delete({ + expiresAt: LessThan(new Date()), + }); + } + + private async updateDeviceSession( + userId: number, + deviceFingerprint: string, + ipAddress: string, + userAgent: string, + ): Promise { + const existingSession = await this.deviceSessionRepository.findOne({ + where: { userId, deviceFingerprint }, + }); + + if (existingSession) { + await this.deviceSessionRepository.update( + { id: existingSession.id }, + { + lastActivity: new Date(), + ipAddress, + userAgent, + isActive: true, + } + ); + } else { + const activeSessions = await this.deviceSessionRepository.count({ + where: { userId, isActive: true }, + }); + + if (activeSessions >= 5) { + const oldestSession = await this.deviceSessionRepository.findOne({ + where: { userId, isActive: true }, + order: { lastActivity: 'ASC' }, + }); + + if (oldestSession) { + await this.deviceSessionRepository.update( + { id: oldestSession.id }, + { isActive: false } + ); + + await this.refreshTokenRepository.update( + { userId, deviceFingerprint: oldestSession.deviceFingerprint }, + { isRevoked: true } + ); + } + } + + await this.deviceSessionRepository.save({ + userId, + deviceFingerprint, + ipAddress, + userAgent, + deviceName: this.extractDeviceName(userAgent), + }); + } + } + + private extractDeviceName(userAgent: string): string { + if (!userAgent) return 'Unknown Device'; + + if (userAgent.includes('Mobile')) return 'Mobile Device'; + if (userAgent.includes('Tablet')) return 'Tablet'; + if (userAgent.includes('Windows')) return 'Windows PC'; + if (userAgent.includes('Mac')) return 'Mac'; + if (userAgent.includes('Linux')) return 'Linux PC'; + + return 'Unknown Device'; + } + + async getUserDeviceSessions(userId: number): Promise { + return this.deviceSessionRepository.find({ + where: { userId, isActive: true }, + order: { lastActivity: 'DESC' }, + }); + } + + async revokeDeviceSession(userId: number, sessionId: number): Promise { + const session = await this.deviceSessionRepository.findOne({ + where: { id: sessionId, userId }, + }); + + if (session) { + await this.deviceSessionRepository.update( + { id: sessionId }, + { isActive: false } + ); + + await this.refreshTokenRepository.update( + { userId, deviceFingerprint: session.deviceFingerprint }, + { isRevoked: true } + ); + } + } +} diff --git a/src/modules/auth/services/two-factor.service.ts b/src/modules/auth/services/two-factor.service.ts new file mode 100644 index 0000000..5bb7e14 --- /dev/null +++ b/src/modules/auth/services/two-factor.service.ts @@ -0,0 +1,243 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import * as crypto from 'crypto'; +import { UserTwoFactorEntity } from '../entities/user-two-factor.entity'; +import { SecurityAuditLogEntity, SecurityEventType } from '../entities/security-audit-log.entity'; +import { UserEntity } from '../../user/entities/user.entity'; + +@Injectable() +export class TwoFactorService { + constructor( + @InjectRepository(UserTwoFactorEntity) + private readonly twoFactorRepository: Repository, + @InjectRepository(SecurityAuditLogEntity) + private readonly auditLogRepository: Repository, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) {} + + async generateSecret(userId: number): Promise<{ secret: string; qrCodeUrl: string; backupCodes: string[] }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new Error('User not found'); + } + + const secret = speakeasy.generateSecret({ + name: `SmartBank (${user.email})`, + issuer: 'SmartBank', + length: 32, + }); + + const backupCodes = this.generateBackupCodes(); + + let twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (twoFactor) { + await this.twoFactorRepository.update( + { userId }, + { + secret: secret.base32, + backupCodes: JSON.stringify(backupCodes), + recoveryCodesUsed: 0, + } + ); + } else { + await this.twoFactorRepository.save({ + userId, + secret: secret.base32, + backupCodes: JSON.stringify(backupCodes), + isEnabled: false, + }); + } + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url); + + return { + secret: secret.base32, + qrCodeUrl, + backupCodes, + }; + } + + async enableTwoFactor(userId: number, token: string): Promise { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (!twoFactor) { + throw new Error('Two-factor authentication not set up'); + } + + const isValid = speakeasy.totp.verify({ + secret: twoFactor.secret, + encoding: 'base32', + token, + window: 1, + }); + + if (isValid) { + await this.twoFactorRepository.update( + { userId }, + { isEnabled: true, lastUsedAt: new Date() } + ); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.TWO_FACTOR_ENABLED; + auditLog.description = 'Two-factor authentication enabled'; + auditLog.userId = userId; + auditLog.metadata = {}; + await this.auditLogRepository.save(auditLog); + + return true; + } + + return false; + } + + async disableTwoFactor(userId: number, token: string): Promise { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (!twoFactor || !twoFactor.isEnabled) { + return false; + } + + const isValid = speakeasy.totp.verify({ + secret: twoFactor.secret, + encoding: 'base32', + token, + window: 1, + }); + + if (isValid) { + await this.twoFactorRepository.update( + { userId }, + { isEnabled: false } + ); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.TWO_FACTOR_DISABLED; + auditLog.description = 'Two-factor authentication disabled'; + auditLog.userId = userId; + auditLog.metadata = {}; + await this.auditLogRepository.save(auditLog); + + return true; + } + + return false; + } + + async verifyToken(userId: number, token: string): Promise { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (!twoFactor || !twoFactor.isEnabled) { + return false; + } + + const isValid = speakeasy.totp.verify({ + secret: twoFactor.secret, + encoding: 'base32', + token, + window: 1, + }); + + if (isValid) { + await this.twoFactorRepository.update( + { userId }, + { lastUsedAt: new Date() } + ); + } + + return isValid; + } + + async verifyBackupCode(userId: number, backupCode: string): Promise { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (!twoFactor || !twoFactor.isEnabled || !twoFactor.backupCodes) { + return false; + } + + const backupCodes: string[] = JSON.parse(twoFactor.backupCodes); + const codeIndex = backupCodes.indexOf(backupCode); + + if (codeIndex !== -1) { + backupCodes.splice(codeIndex, 1); + + await this.twoFactorRepository.update( + { userId }, + { + backupCodes: JSON.stringify(backupCodes), + recoveryCodesUsed: twoFactor.recoveryCodesUsed + 1, + lastUsedAt: new Date(), + } + ); + + const auditLog = new SecurityAuditLogEntity(); + auditLog.eventType = SecurityEventType.LOGIN_SUCCESS; + auditLog.description = 'Login with backup code'; + auditLog.userId = userId; + auditLog.metadata = { method: 'backup_code' }; + await this.auditLogRepository.save(auditLog); + + return true; + } + + return false; + } + + async generateNewBackupCodes(userId: number): Promise { + const backupCodes = this.generateBackupCodes(); + + await this.twoFactorRepository.update( + { userId }, + { + backupCodes: JSON.stringify(backupCodes), + recoveryCodesUsed: 0, + } + ); + + return backupCodes; + } + + async isTwoFactorEnabled(userId: number): Promise { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + return twoFactor?.isEnabled || false; + } + + async getTwoFactorStatus(userId: number): Promise<{ + isEnabled: boolean; + backupCodesRemaining: number; + lastUsedAt: Date | null; + }> { + const twoFactor = await this.twoFactorRepository.findOne({ where: { userId } }); + + if (!twoFactor) { + return { + isEnabled: false, + backupCodesRemaining: 0, + lastUsedAt: null, + }; + } + + const backupCodes = twoFactor.backupCodes ? JSON.parse(twoFactor.backupCodes) : []; + + return { + isEnabled: twoFactor.isEnabled, + backupCodesRemaining: backupCodes.length, + lastUsedAt: twoFactor.lastUsedAt, + }; + } + + private generateBackupCodes(): string[] { + const codes: string[] = []; + + for (let i = 0; i < 10; i++) { + const code = crypto.randomBytes(4).toString('hex').toUpperCase(); + codes.push(`${code.slice(0, 4)}-${code.slice(4, 8)}`); + } + + return codes; + } +} diff --git a/src/modules/auth/validators/password-strength.validator.ts b/src/modules/auth/validators/password-strength.validator.ts new file mode 100644 index 0000000..b323ab1 --- /dev/null +++ b/src/modules/auth/validators/password-strength.validator.ts @@ -0,0 +1,15 @@ +import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator'; +import { UtilsService } from '../../../utils/services/utils.service'; + +@ValidatorConstraint({ name: 'isPasswordStrong', async: false }) +export class IsPasswordStrong implements ValidatorConstraintInterface { + validate(password: string, args: ValidationArguments) { + const validation = UtilsService.validatePasswordStrength(password); + return validation.isValid; + } + + defaultMessage(args: ValidationArguments) { + const validation = UtilsService.validatePasswordStrength(args.value); + return validation.errors.join(', '); + } +} diff --git a/src/utils/services/utils.service.ts b/src/utils/services/utils.service.ts index 451aba1..03c354a 100644 --- a/src/utils/services/utils.service.ts +++ b/src/utils/services/utils.service.ts @@ -26,7 +26,71 @@ export class UtilsService { } static generateHash(password: string): string { - return bcrypt.hashSync(password, 10); + return bcrypt.hashSync(password, 12); + } + + static async generateHashAsync(password: string): Promise { + return bcrypt.hash(password, 12); + } + + static validatePasswordStrength(password: string): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/\d/.test(password)) { + errors.push('Password must contain at least one number'); + } + + if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + errors.push('Password must contain at least one special character'); + } + + const commonPasswords = [ + 'password', '123456', '123456789', 'qwerty', 'abc123', + 'password123', 'admin', 'letmein', 'welcome', 'monkey' + ]; + + if (commonPasswords.includes(password.toLowerCase())) { + errors.push('Password is too common'); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + static validatePinCodeStrength(pinCode: number): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + const pinStr = pinCode.toString(); + + if (pinStr.length !== 6) { + errors.push('PIN code must be exactly 6 digits'); + } + + if (/^(\d)\1{5}$/.test(pinStr)) { + errors.push('PIN code cannot be all the same digit'); + } + + if (/^(012345|123456|234567|345678|456789|567890|654321|543210|432109|321098|210987|109876)$/.test(pinStr)) { + errors.push('PIN code cannot be a sequential pattern'); + } + + return { + isValid: errors.length === 0, + errors + }; } static validateHash(password: string, hash: string): Promise { diff --git a/yarn.lock b/yarn.lock index 51a1c82..a569807 100644 --- a/yarn.lock +++ b/yarn.lock @@ -701,6 +701,11 @@ consola "^2.3.0" node-fetch "^2.3.0" +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + "@schematics/schematics@0.901.9": version "0.901.9" resolved "https://registry.yarnpkg.com/@schematics/schematics/-/schematics-0.901.9.tgz#298b59acfe5b478ba12c80feb42a9e618ef2522e" @@ -1009,6 +1014,13 @@ resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.4.tgz#8772fcd0418e3cd2cc171555d73007415051f4b2" integrity sha1-h3L80EGOPNLMFxVV1zAHQVBR9LI= +"@types/qrcode@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac" + integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.9.3" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.3.tgz#b755a0934564a200d3efdf88546ec93c369abd03" @@ -1032,6 +1044,13 @@ resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== +"@types/speakeasy@^2.0.10": + version "2.0.10" + resolved "https://registry.yarnpkg.com/@types/speakeasy/-/speakeasy-2.0.10.tgz#a1f0e474696abd165e544478b9b02549778fa4e2" + integrity sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA== + dependencies: + "@types/node" "*" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1539,6 +1558,15 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== +argon2@^0.43.1: + version "0.43.1" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.43.1.tgz#8155ae67fd4ca2a7364364ca3e45e0d8fbbc0ee9" + integrity sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w== + dependencies: + "@phc/format" "^1.0.0" + node-addon-api "^8.4.0" + node-gyp-build "^4.8.4" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1774,6 +1802,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base32.js@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.0.1.tgz#d045736a57b1f6c139f0c7df42518a84e91bb2ba" + integrity sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ== + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -2977,6 +3010,11 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -6256,6 +6294,11 @@ node-addon-api@^3.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.0.tgz#812446a1001a54f71663bed188314bba07e09247" integrity sha512-sSHCgWfJ+Lui/u+0msF3oyCgvdkhxDbkCS6Q8uiJquzOimkJBvX6hl5aSSA7DR1XbMpdM8r7phjcF63sF4rkKg== +node-addon-api@^8.4.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.5.0.tgz#c91b2d7682fa457d2e1c388150f0dff9aafb8f3f" + integrity sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A== + node-emoji@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" @@ -6268,6 +6311,11 @@ node-fetch@^2.3.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== +node-gyp-build@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -7038,6 +7086,11 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -7349,6 +7402,15 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qrcode@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88" + integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg== + dependencies: + dijkstrajs "^1.0.1" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" @@ -8102,6 +8164,13 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== +speakeasy@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/speakeasy/-/speakeasy-2.0.0.tgz#85c91a071b09a5cb8642590d983566165f57613a" + integrity sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw== + dependencies: + base32.js "0.0.1" + specificity@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019"