From f9991127f83cb2e30cb863cfd590158cb193f867 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 17 Oct 2026 09:34:34 +0000 Subject: [PATCH] fix(backend): secure risk reads, add sessions/2FA, and clean lint errors Closes #1473 Closes #1467 Closes #1466 Closes #1458 --- backend/package.json | 2 +- backend/src/auth/auth.controller.ts | 121 +++++++++++- backend/src/auth/auth.module.ts | 12 +- backend/src/auth/auth.service.ts | 182 ++++++++++++++++-- backend/src/auth/dto/two-factor.dto.ts | 30 +++ .../auth/entities/refresh-session.entity.ts | 35 ++++ .../src/auth/guards/jwt-auth.guard.spec.ts | 1 - .../auth/interfaces/jwt-payload.interface.ts | 1 + backend/src/auth/two-factor.service.ts | 166 ++++++++++++++++ backend/src/common/api-contracts.ts | 4 +- backend/src/common/cors.config.spec.ts | 6 +- .../middleware/logger.middleware.spec.ts | 1 - .../dispute-reason-classifier.service.spec.ts | 1 - backend/src/documents/documents.service.ts | 8 +- .../pipes/file-validation.pipe.spec.ts | 2 +- .../documents/pipes/file-validation.pipe.ts | 2 +- .../external-validation.service.ts | 6 +- backend/src/health/health.controller.ts | 2 +- backend/src/health/redis.health.spec.ts | 2 +- backend/src/mail/mail.service.spec.ts | 4 +- .../risk-assessment.controller.ts | 55 +++++- .../risk-assessment.service.ts | 25 +++ backend/src/stellar/stellar.service.spec.ts | 7 +- .../verification.controller.spec.ts | 1 - backend/tsconfig.json | 1 + 25 files changed, 609 insertions(+), 68 deletions(-) create mode 100644 backend/src/auth/dto/two-factor.dto.ts create mode 100644 backend/src/auth/entities/refresh-session.entity.ts create mode 100644 backend/src/auth/two-factor.service.ts diff --git a/backend/package.json b/backend/package.json index 99a8d75b..88f43a97 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,7 @@ "start:dev": "node ../contracts/sync.mjs && nest start --watch", "start:debug": "node ../contracts/sync.mjs && nest start --debug --watch", "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 088bcc35..ab1d2a66 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,9 +1,13 @@ import { Body, Controller, - Post, + Delete, Get, Header, + Param, + ParseUUIDPipe, + Patch, + Post, Req, Res, UseGuards, @@ -16,10 +20,17 @@ import { Request, Response } from 'express'; import { Profile as GoogleProfile } from 'passport-google-oauth20'; import { Profile as GithubProfile } from 'passport-github2'; -import { AuthService } from './auth.service'; +import { AuthService, SessionContext } from './auth.service'; +import { TwoFactorService } from './two-factor.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; import { LoginAuthDto } from './dto/login-auth.dto'; import { RefreshAuthDto } from './dto/refresh-auth.dto'; +import { + ChangePasswordDto, + DisableTwoFactorDto, + TwoFactorChallengeDto, + TwoFactorCodeDto, +} from './dto/two-factor.dto'; import { getFrontendUrl, isAllowedFrontendOrigin, @@ -39,6 +50,7 @@ import { User } from '../users/entities/user.entity'; export class AuthController { constructor( private readonly authService: AuthService, + private readonly twoFactorService: TwoFactorService, private readonly configService: ConfigService, ) {} @@ -49,7 +61,10 @@ export class AuthController { @Req() request?: Request, ) { this.assertTrustedBrowserRequest(request); - const tokens = await this.authService.register(dto); + const tokens = await this.authService.register( + dto, + this.getSessionContext(request), + ); setAuthCookies(response, tokens, this.configService); return tokens; } @@ -61,9 +76,15 @@ export class AuthController { @Req() request?: Request, ) { this.assertTrustedBrowserRequest(request); - const tokens = await this.authService.login(dto); - setAuthCookies(response, tokens, this.configService); - return tokens; + const result = await this.authService.login( + dto, + this.getSessionContext(request), + ); + + if ('access_token' in result) { + setAuthCookies(response, result, this.configService); + } + return result; } @Post('refresh') @@ -77,9 +98,12 @@ export class AuthController { request.headers.cookie, REFRESH_COOKIE_NAME, ); - const tokens = await this.authService.refreshToken({ - refreshToken: cookieRefreshToken ?? dto?.refreshToken, - }); + const tokens = await this.authService.refreshToken( + { + refreshToken: cookieRefreshToken ?? dto?.refreshToken, + }, + this.getSessionContext(request), + ); setAuthCookies(response, tokens, this.configService); return tokens; } @@ -129,6 +153,74 @@ export class AuthController { }; } + @Get('sessions') + @UseGuards(JwtAuthGuard) + listSessions(@Req() request: Request & { user?: User }) { + return this.authService.listSessions(request.user!.id); + } + + @Delete('sessions/:id') + @UseGuards(JwtAuthGuard) + revokeSession( + @Req() request: Request & { user?: User }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.authService.revokeSession(request.user!.id, id); + } + + @Patch('change-password') + @UseGuards(JwtAuthGuard) + changePassword( + @Req() request: Request & { user?: User }, + @Body() dto: ChangePasswordDto, + ) { + return this.authService.changePassword( + request.user!.id, + dto.currentPassword, + dto.newPassword, + ); + } + + @Post('2fa/setup') + @UseGuards(JwtAuthGuard) + setupTwoFactor(@Req() request: Request & { user?: User }) { + return this.twoFactorService.setup(request.user!); + } + + @Post('2fa/verify') + @UseGuards(JwtAuthGuard) + verifyTwoFactor( + @Req() request: Request & { user?: User }, + @Body() dto: TwoFactorCodeDto, + ) { + return this.twoFactorService.confirm(request.user!.id, dto.code); + } + + @Post('2fa/challenge') + async challengeTwoFactor( + @Body() dto: TwoFactorChallengeDto, + @Req() request: Request, + @Res({ passthrough: true }) response?: Response, + ) { + this.assertTrustedBrowserRequest(request); + const tokens = await this.authService.completeTwoFactorLogin( + dto.twoFactorToken, + dto.code, + this.getSessionContext(request), + ); + setAuthCookies(response, tokens, this.configService); + return tokens; + } + + @Post('2fa/disable') + @UseGuards(JwtAuthGuard) + disableTwoFactor( + @Req() request: Request & { user?: User }, + @Body() dto: DisableTwoFactorDto, + ) { + return this.twoFactorService.disable(request.user!.id, dto.password); + } + @Get('google') @UseGuards(AuthGuard('google')) googleAuth() { @@ -158,6 +250,7 @@ export class AuthController { const tokens = await this.authService.handleOAuthLogin( email, fullName || email, + this.getSessionContext(req), ); return this.redirectWithCookies(tokens, res); @@ -192,6 +285,7 @@ export class AuthController { const tokens = await this.authService.handleOAuthLogin( identifier, fullName, + this.getSessionContext(req), ); return this.redirectWithCookies(tokens, res); @@ -200,7 +294,7 @@ export class AuthController { private redirectWithCookies(tokens: AuthTokens, res: Response) { setAuthCookies(res, tokens, this.configService); const redirectUrl = new URL(getFrontendUrl(this.configService)); - const basePath = redirectUrl.pathname.replace(/\/+$/, ""); + const basePath = redirectUrl.pathname.replace(/\/+$/, ''); redirectUrl.pathname = `${basePath}/auth/oauth/callback`; redirectUrl.search = ''; redirectUrl.hash = new URLSearchParams({ @@ -209,6 +303,13 @@ export class AuthController { return res.redirect(redirectUrl.toString()); } + private getSessionContext(request?: Request): SessionContext { + return { + userAgent: request?.headers['user-agent'], + ipAddress: request?.ip, + }; + } + private assertTrustedBrowserRequest(request?: Request): void { if (!request) return; diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 95967f20..4507702e 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -2,9 +2,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { TwoFactorService } from './two-factor.service'; +import { RefreshSession } from './entities/refresh-session.entity'; import { UsersModule } from '../users/users.module'; import { JwtStrategy } from './strategies/jwt.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; @@ -13,6 +16,7 @@ import { GithubStrategy } from './strategies/github.strategy'; @Module({ imports: [ UsersModule, + TypeOrmModule.forFeature([RefreshSession]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ConfigModule], @@ -27,7 +31,13 @@ import { GithubStrategy } from './strategies/github.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, GoogleStrategy, GithubStrategy], + providers: [ + AuthService, + TwoFactorService, + JwtStrategy, + GoogleStrategy, + GithubStrategy, + ], exports: [AuthService, JwtModule, PassportModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c9235d63..a6c77ade 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,11 +1,14 @@ import { ConflictException, Injectable, + NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { UsersService } from '../users/users.service'; @@ -14,6 +17,18 @@ import { LoginAuthDto } from './dto/login-auth.dto'; import { RefreshAuthDto } from './dto/refresh-auth.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { User, UserRole } from '../users/entities/user.entity'; +import { RefreshSession } from './entities/refresh-session.entity'; +import { TwoFactorService } from './two-factor.service'; + +export interface SessionContext { + userAgent?: string; + ipAddress?: string; +} + +export interface TwoFactorRequired { + requiresTwoFactor: true; + twoFactorToken: string; +} @Injectable() export class AuthService { @@ -25,9 +40,12 @@ export class AuthService { private readonly usersService: UsersService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + @InjectRepository(RefreshSession) + private readonly sessionsRepository: Repository, + private readonly twoFactorService: TwoFactorService, ) {} - async register(dto: RegisterAuthDto) { + async register(dto: RegisterAuthDto, context?: SessionContext) { const existing = await this.usersService.findByEmail(dto.email); if (existing) { throw new ConflictException('Email already registered'); @@ -42,19 +60,55 @@ export class AuthService { isVerified: false, }); - const access_token = await this.generateAccessToken(user); - const refresh_token = await this.generateRefreshToken(user); - return { access_token, refresh_token }; + return this.issueTokens(user, context); } - async login(dto: LoginAuthDto) { + async login( + dto: LoginAuthDto, + context?: SessionContext, + ): Promise<{ access_token: string; refresh_token: string } | TwoFactorRequired> { const user = await this.validateCredentials(dto.email, dto.password); - const access_token = await this.generateAccessToken(user); - const refresh_token = await this.generateRefreshToken(user); - return { access_token, refresh_token }; + + if (user.twoFactorEnabled) { + return { + requiresTwoFactor: true, + twoFactorToken: await this.generateTwoFactorToken(user), + }; + } + + return this.issueTokens(user, context); } - async handleOAuthLogin(email: string, fullName?: string) { + async completeTwoFactorLogin( + twoFactorToken: string, + code: string, + context?: SessionContext, + ) { + let payload: { sub: string; purpose?: string }; + try { + payload = await this.jwtService.verifyAsync(twoFactorToken); + } catch { + throw new UnauthorizedException('Invalid two-factor token'); + } + + if (payload.purpose !== '2fa') { + throw new UnauthorizedException('Invalid two-factor token'); + } + + const user = await this.usersService.findById(payload.sub); + if (!user) { + throw new UnauthorizedException('Invalid two-factor token'); + } + + await this.twoFactorService.challenge(user.id, code); + return this.issueTokens(user, context); + } + + async handleOAuthLogin( + email: string, + fullName?: string, + context?: SessionContext, + ) { if (!email) { throw new BadRequestException( 'Email is required from the OAuth provider', @@ -78,12 +132,10 @@ export class AuthService { }); } - const access_token = await this.generateAccessToken(user); - const refresh_token = await this.generateRefreshToken(user); - return { access_token, refresh_token }; + return this.issueTokens(user, context); } - async refreshToken(dto: RefreshAuthDto) { + async refreshToken(dto: RefreshAuthDto, context?: SessionContext) { const refreshToken = dto?.refreshToken?.trim(); if (!refreshToken) { throw new BadRequestException('Refresh token is required'); @@ -106,10 +158,19 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } + if (payload.sid) { + const session = await this.sessionsRepository.findOne({ + where: { id: payload.sid, userId: user.id }, + }); + if (!session || session.revokedAt) { + throw new UnauthorizedException('Invalid refresh token'); + } + session.revokedAt = new Date(); + await this.sessionsRepository.save(session); + } + await this.revokeRefreshToken(refreshToken, payload); - const access_token = await this.generateAccessToken(user); - const refresh_token = await this.generateRefreshToken(user); - return { access_token, refresh_token }; + return this.issueTokens(user, context); } catch { throw new UnauthorizedException('Invalid refresh token'); } @@ -138,6 +199,81 @@ export class AuthService { return this.tokenBlacklist.has(token); } + listSessions(userId: string): Promise { + return this.sessionsRepository.find({ + where: { userId, revokedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + } + + async revokeSession( + userId: string, + sessionId: string, + ): Promise<{ message: string }> { + const session = await this.sessionsRepository.findOne({ + where: { id: sessionId, userId }, + }); + if (!session) { + throw new NotFoundException('Session not found'); + } + + if (!session.revokedAt) { + session.revokedAt = new Date(); + await this.sessionsRepository.save(session); + } + + return { message: 'Session revoked' }; + } + + async changePassword( + userId: string, + currentPassword: string, + newPassword: string, + ): Promise<{ message: string }> { + const user = await this.usersService.findById(userId); + if (!user?.passwordHash) { + throw new BadRequestException('Password is not set for this account'); + } + + const passwordMatches = await bcrypt.compare( + currentPassword, + user.passwordHash, + ); + if (!passwordMatches) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const passwordHash = await bcrypt.hash(newPassword, 12); + await this.usersService.update(userId, { passwordHash }); + await this.sessionsRepository.update( + { userId }, + { revokedAt: new Date() }, + ); + + return { message: 'Password changed. Please sign in again.' }; + } + + private async issueTokens(user: User, context?: SessionContext) { + const session = await this.sessionsRepository.save( + this.sessionsRepository.create({ + userId: user.id, + userAgent: context?.userAgent, + ipAddress: context?.ipAddress, + }), + ); + + const access_token = await this.generateAccessToken(user); + const refresh_token = await this.generateRefreshToken(user, session.id); + return { access_token, refresh_token }; + } + + private async generateTwoFactorToken(user: User) { + return this.jwtService.signAsync( + { sub: user.id, purpose: '2fa' }, + { expiresIn: '5m' }, + ); + } + private async blacklistAccessToken(token: string): Promise { try { const payload = await this.jwtService.verifyAsync(token); @@ -163,6 +299,13 @@ export class AuthService { } if (!payload) return; + if (payload.sid) { + await this.sessionsRepository.update( + { id: payload.sid }, + { revokedAt: new Date() }, + ); + } + this.rememberToken( this.refreshTokenBlacklist, token, @@ -217,11 +360,12 @@ export class AuthService { return this.jwtService.signAsync(payload); } - private async generateRefreshToken(user: User) { + private async generateRefreshToken(user: User, sessionId?: string) { const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role, + sid: sessionId, }; return this.jwtService.signAsync(payload, { @@ -240,8 +384,4 @@ export class AuthService { private getRefreshExpiration() { return this.configService.get('JWT_REFRESH_EXPIRATION') ?? '7d'; } - - private getAccessExpiration() { - return this.configService.get('JWT_EXPIRATION') ?? '15m'; - } } diff --git a/backend/src/auth/dto/two-factor.dto.ts b/backend/src/auth/dto/two-factor.dto.ts new file mode 100644 index 00000000..375fb1a1 --- /dev/null +++ b/backend/src/auth/dto/two-factor.dto.ts @@ -0,0 +1,30 @@ +import { IsString, Length, MinLength } from 'class-validator'; + +export class TwoFactorCodeDto { + @IsString() + @Length(4, 32) + code: string; +} + +export class TwoFactorChallengeDto { + @IsString() + twoFactorToken: string; + + @IsString() + @Length(4, 32) + code: string; +} + +export class DisableTwoFactorDto { + @IsString() + password: string; +} + +export class ChangePasswordDto { + @IsString() + currentPassword: string; + + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/backend/src/auth/entities/refresh-session.entity.ts b/backend/src/auth/entities/refresh-session.entity.ts new file mode 100644 index 00000000..17c64953 --- /dev/null +++ b/backend/src/auth/entities/refresh-session.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('refresh_sessions') +export class RefreshSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string | null; + + @Column({ name: 'ip_address', nullable: true }) + ipAddress?: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt?: Date | null; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt?: Date | null; +} diff --git a/backend/src/auth/guards/jwt-auth.guard.spec.ts b/backend/src/auth/guards/jwt-auth.guard.spec.ts index 125aad76..2552c7f9 100644 --- a/backend/src/auth/guards/jwt-auth.guard.spec.ts +++ b/backend/src/auth/guards/jwt-auth.guard.spec.ts @@ -1,5 +1,4 @@ import { JwtAuthGuard } from './jwt-auth.guard'; -import { ExecutionContext } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; describe('JwtAuthGuard', () => { diff --git a/backend/src/auth/interfaces/jwt-payload.interface.ts b/backend/src/auth/interfaces/jwt-payload.interface.ts index 37e2e2ed..7f3d6479 100644 --- a/backend/src/auth/interfaces/jwt-payload.interface.ts +++ b/backend/src/auth/interfaces/jwt-payload.interface.ts @@ -4,6 +4,7 @@ export interface JwtPayload { sub: string; email: string; role: UserRole; + sid?: string; exp?: number; iat?: number; } diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts new file mode 100644 index 00000000..173e3baf --- /dev/null +++ b/backend/src/auth/two-factor.service.ts @@ -0,0 +1,166 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { createHash, randomBytes } from 'crypto'; +import * as bcrypt from 'bcrypt'; +import * as speakeasy from 'speakeasy'; + +import { UsersService } from '../users/users.service'; +import { User } from '../users/entities/user.entity'; +import { decryptBuffer, encryptBuffer } from '../common/crypto.util'; + +const MAX_ATTEMPTS = 5; +const ATTEMPT_WINDOW_MS = 60_000; + +@Injectable() +export class TwoFactorService { + private readonly attempts = new Map< + string, + { count: number; windowStart: number } + >(); + + constructor(private readonly usersService: UsersService) {} + + async setup(user: User) { + const secret = speakeasy.generateSecret({ + length: 20, + name: `SMALDA (${user.email})`, + issuer: 'SMALDA', + }); + + // The TOTP secret is encrypted at rest; only the user gets the plain value. + const encrypted = encryptBuffer(Buffer.from(secret.base32, 'utf8')).toString( + 'base64', + ); + await this.usersService.update(user.id, { + twoFactorSecret: encrypted, + twoFactorEnabled: false, + }); + + return { secret: secret.base32, otpauthUrl: secret.otpauth_url }; + } + + async confirm(userId: string, code: string) { + const user = await this.getUserWithSecret(userId); + this.assertNotRateLimited(userId); + + if (!this.verifyTotp(user, code)) { + this.recordFailure(userId); + throw new UnauthorizedException('Invalid two-factor code'); + } + + const backupCodes = Array.from({ length: 8 }, () => + randomBytes(4).toString('hex'), + ); + await this.usersService.update(userId, { + twoFactorEnabled: true, + twoFactorBackupCodes: JSON.stringify(backupCodes.map(hashCode)), + }); + this.clearFailures(userId); + + // The plain backup codes are shown once and stored only as hashes. + return { enabled: true, backupCodes }; + } + + async challenge(userId: string, code: string) { + const user = await this.getUserWithSecret(userId); + this.assertNotRateLimited(userId); + + if (this.verifyTotp(user, code) || (await this.consumeBackupCode(user, code))) { + this.clearFailures(userId); + return true; + } + + this.recordFailure(userId); + throw new UnauthorizedException('Invalid two-factor code'); + } + + async disable(userId: string, password: string) { + const user = await this.usersService.findById(userId); + if (!user?.passwordHash) { + throw new BadRequestException('Password is not set for this account'); + } + + const passwordMatches = await bcrypt.compare(password, user.passwordHash); + if (!passwordMatches) { + throw new UnauthorizedException('Invalid password'); + } + + await this.usersService.update(userId, { + twoFactorEnabled: false, + twoFactorSecret: null as unknown as undefined, + twoFactorBackupCodes: null as unknown as undefined, + }); + + return { enabled: false }; + } + + private async getUserWithSecret(userId: string): Promise { + const user = await this.usersService.findById(userId); + if (!user?.twoFactorSecret) { + throw new BadRequestException('Two-factor authentication is not set up'); + } + return user; + } + + private verifyTotp(user: User, code: string): boolean { + const secret = decryptBuffer( + Buffer.from(user.twoFactorSecret as string, 'base64'), + ).toString('utf8'); + + return speakeasy.totp.verify({ + secret, + encoding: 'base32', + token: code, + window: 1, + }); + } + + private async consumeBackupCode(user: User, code: string): Promise { + const stored: string[] = JSON.parse(user.twoFactorBackupCodes || '[]'); + const index = stored.indexOf(hashCode(code)); + if (index < 0) return false; + + stored.splice(index, 1); + await this.usersService.update(user.id, { + twoFactorBackupCodes: JSON.stringify(stored), + }); + return true; + } + + private assertNotRateLimited(userId: string): void { + const entry = this.attempts.get(userId); + if (!entry) return; + + if (Date.now() - entry.windowStart > ATTEMPT_WINDOW_MS) { + this.attempts.delete(userId); + return; + } + + if (entry.count >= MAX_ATTEMPTS) { + throw new UnauthorizedException( + 'Too many two-factor attempts. Try again later.', + ); + } + } + + private recordFailure(userId: string): void { + const now = Date.now(); + const entry = this.attempts.get(userId); + if (!entry || now - entry.windowStart > ATTEMPT_WINDOW_MS) { + this.attempts.set(userId, { count: 1, windowStart: now }); + return; + } + entry.count += 1; + } + + private clearFailures(userId: string): void { + this.attempts.delete(userId); + } +} + +function hashCode(code: string): string { + return createHash('sha256').update(code).digest('hex'); +} diff --git a/backend/src/common/api-contracts.ts b/backend/src/common/api-contracts.ts index 243345d2..e772cb3b 100644 --- a/backend/src/common/api-contracts.ts +++ b/backend/src/common/api-contracts.ts @@ -27,7 +27,9 @@ type ApiContracts = { }; }; -export const apiContracts = require('../contracts/api-contracts.json') as ApiContracts; +import apiContractsJson from '../contracts/api-contracts.json'; + +export const apiContracts = apiContractsJson as ApiContracts; const contractFormats: Array<[string | undefined, string]> = [ [apiContracts.register.email.format, 'email'], diff --git a/backend/src/common/cors.config.spec.ts b/backend/src/common/cors.config.spec.ts index c755e7cc..2bdd942d 100644 --- a/backend/src/common/cors.config.spec.ts +++ b/backend/src/common/cors.config.spec.ts @@ -1,5 +1,7 @@ import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import { ConfigService } from '@nestjs/config'; +import { buildCorsOptions, getFrontendUrl } from './cors.config'; type OriginCallback = ( origin: string | undefined, @@ -42,9 +44,9 @@ describe('CORS configuration', () => { it('should enable credentials by default', () => { const config = createConfig(); expect(config.credentials).toBe(true); + }); +}); -import { ConfigService } from '@nestjs/config'; -import { buildCorsOptions, getFrontendUrl } from './cors.config'; describe('buildCorsOptions', () => { function createConfigService(values: Record): ConfigService { diff --git a/backend/src/common/middleware/logger.middleware.spec.ts b/backend/src/common/middleware/logger.middleware.spec.ts index 531c863c..3e7fe3f8 100644 --- a/backend/src/common/middleware/logger.middleware.spec.ts +++ b/backend/src/common/middleware/logger.middleware.spec.ts @@ -1,7 +1,6 @@ import { LoggerMiddleware } from './logger.middleware'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { Test } from '@nestjs/testing'; -import { Logger } from 'winston'; import { Request, Response } from 'express'; import { AccessLogsService } from '../../access-logs/access-logs.service'; diff --git a/backend/src/dispute/dispute-reason-classifier.service.spec.ts b/backend/src/dispute/dispute-reason-classifier.service.spec.ts index a6791053..a221e7f2 100644 --- a/backend/src/dispute/dispute-reason-classifier.service.spec.ts +++ b/backend/src/dispute/dispute-reason-classifier.service.spec.ts @@ -1,6 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { DisputeReasonClassifierService } from './dispute-reason-classifier.service'; import { DisputeReason } from './entities/dispute-reason.entity'; diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index 6dfb694b..b8f15ab2 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -1,12 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - Repository, - Between, - LessThanOrEqual, - MoreThanOrEqual, - ILike, -} from 'typeorm'; +import { Repository, ILike } from 'typeorm'; import { promises as fs } from 'fs'; import { Document, DocumentStatus } from './entities/document.entity'; diff --git a/backend/src/documents/pipes/file-validation.pipe.spec.ts b/backend/src/documents/pipes/file-validation.pipe.spec.ts index 5cf00850..f80d7a8c 100644 --- a/backend/src/documents/pipes/file-validation.pipe.spec.ts +++ b/backend/src/documents/pipes/file-validation.pipe.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { PDFDocument } from 'pdf-lib'; -const sharp = require('sharp') as (...args: any[]) => any; +import sharp from 'sharp'; import { FileValidationPipe } from './file-validation.pipe'; function createFile( diff --git a/backend/src/documents/pipes/file-validation.pipe.ts b/backend/src/documents/pipes/file-validation.pipe.ts index ac6ef449..4c67a72a 100644 --- a/backend/src/documents/pipes/file-validation.pipe.ts +++ b/backend/src/documents/pipes/file-validation.pipe.ts @@ -8,7 +8,7 @@ import { DOCUMENT_ALLOWED_MIME_TYPES, DOCUMENT_MAX_FILE_SIZE_BYTES, } from '../../common/api-contracts'; -const sharp = require('sharp') as (...args: any[]) => any; +import sharp from 'sharp'; const ALLOWED_MIME_TYPES = DOCUMENT_ALLOWED_MIME_TYPES; const MAX_FILE_SIZE_BYTES = DOCUMENT_MAX_FILE_SIZE_BYTES; diff --git a/backend/src/external-validation/external-validation.service.ts b/backend/src/external-validation/external-validation.service.ts index 5da0776e..34747776 100644 --- a/backend/src/external-validation/external-validation.service.ts +++ b/backend/src/external-validation/external-validation.service.ts @@ -12,11 +12,7 @@ import { ValidationResult, ValidationType, } from './entities/validation-request.entity'; -import { - CreateValidationRequestDto, - QueryValidationRequestDto, - RetryValidationDto, -} from './dto/validation-request.dto'; +import { CreateValidationRequestDto } from './dto/validation-request.dto'; import { IValidationProvider } from './interfaces/validation-provider.interface'; import { LandRegistryProvider } from './providers/land-registry.provider'; import { GovernmentIdProvider } from './providers/government-id.provider'; diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index bba5054b..e92c88b9 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Inject } from '@nestjs/common'; +import { Controller, Get } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; diff --git a/backend/src/health/redis.health.spec.ts b/backend/src/health/redis.health.spec.ts index d6d09cb2..92a6204f 100644 --- a/backend/src/health/redis.health.spec.ts +++ b/backend/src/health/redis.health.spec.ts @@ -169,7 +169,7 @@ describe('RedisHealthIndicator', () => { describe('password configuration', () => { it('should pass the password to the Redis constructor when set', async () => { - const IORedis = require('ioredis').default; + const IORedis = jest.requireMock('ioredis').default; const configWithPassword = { ...configValues, diff --git a/backend/src/mail/mail.service.spec.ts b/backend/src/mail/mail.service.spec.ts index caa15929..fc806b73 100644 --- a/backend/src/mail/mail.service.spec.ts +++ b/backend/src/mail/mail.service.spec.ts @@ -1,7 +1,6 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import * as nodemailer from 'nodemailer'; import { ConfigService } from '@nestjs/config'; import { MailService } from './mail.service'; -import { UsersService } from '../users/users.service'; // --------------------------------------------------------------------------- // Mocks — define inside the factory so hoisting is not an issue @@ -23,7 +22,6 @@ jest.mock('nodemailer', () => { }); // Retrieve the mock reference after the module mock is registered -const nodemailer = require('nodemailer'); const mockCreateTransport = nodemailer.createTransport as jest.Mock; const mockUsersService = { diff --git a/backend/src/risk-assessment/risk-assessment.controller.ts b/backend/src/risk-assessment/risk-assessment.controller.ts index 6f203a5e..137e32fc 100644 --- a/backend/src/risk-assessment/risk-assessment.controller.ts +++ b/backend/src/risk-assessment/risk-assessment.controller.ts @@ -1,10 +1,24 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + ForbiddenException, + Get, + NotFoundException, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Request } from 'express'; import { RiskAssessmentService } from './risk-assessment.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ListRiskAssessmentsDto } from './dto/list-risk-assessments.dto'; import { DocumentsService } from '../documents/documents.service'; +import { Document } from '../documents/entities/document.entity'; +import { User, UserRole } from '../users/entities/user.entity'; @ApiTags('documents') @Controller('documents') @@ -17,8 +31,27 @@ export class RiskAssessmentController { @Get(':id/risk') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'Assess the risk score of a document' }) - async getRisk(@Param('id') id: string, @Query('lang') lang?: string) { + @ApiOperation({ summary: 'Get the stored risk assessment of a document' }) + async getRisk( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, + @Query('lang') lang?: string, + ) { + await this.getAccessibleDocument(id, req.user); + // Reads must not mutate data; scoring happens in the queue job. + return this.riskService.getStoredAssessment(id, lang); + } + + @Post(':id/risk/reassess') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Explicitly recompute the risk score of a document' }) + async reassessRisk( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, + @Query('lang') lang?: string, + ) { + await this.getAccessibleDocument(id, req.user); return this.riskService.assessDocument(id, lang); } @@ -54,4 +87,20 @@ export class RiskAssessmentController { totalPages: Math.ceil(result.total / limit), }; } + + private async getAccessibleDocument( + id: string, + user?: User, + ): Promise { + const document = await this.documentsService.findById(id); + if (!document) { + throw new NotFoundException('Document not found'); + } + + if (!user || (document.ownerId !== user.id && user.role !== UserRole.ADMIN)) { + throw new ForbiddenException('Access denied'); + } + + return document; + } } diff --git a/backend/src/risk-assessment/risk-assessment.service.ts b/backend/src/risk-assessment/risk-assessment.service.ts index 652cb232..8c332585 100644 --- a/backend/src/risk-assessment/risk-assessment.service.ts +++ b/backend/src/risk-assessment/risk-assessment.service.ts @@ -121,6 +121,31 @@ export class RiskAssessmentService { }; } + async getStoredAssessment( + documentId: string, + lang?: string, + ): Promise { + const document = await this.documentsService.findById(documentId); + if (!document) { + throw new NotFoundException('Document not found'); + } + + const locale = lang || 'en'; + const flags = (document.riskFlags ?? []) as unknown as RiskFlag[]; + + return { + score: document.riskScore ?? 0, + flags: flags.map((flag) => ({ + flag, + description: + RISK_FLAG_DESCRIPTIONS[flag]?.[locale] || + RISK_FLAG_DESCRIPTIONS[flag]?.['en'] || + flag, + })), + contentAnalysisPossible: document.mimeType === 'application/pdf', + }; + } + private async detectFlags(document: Document): Promise { const flags: RiskFlag[] = []; diff --git a/backend/src/stellar/stellar.service.spec.ts b/backend/src/stellar/stellar.service.spec.ts index f2084a0e..e56e7e07 100644 --- a/backend/src/stellar/stellar.service.spec.ts +++ b/backend/src/stellar/stellar.service.spec.ts @@ -1,6 +1,5 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; import { InternalServerErrorException } from '@nestjs/common'; +import { Networks, Operation, TransactionBuilder } from 'stellar-sdk'; // ─────────────────────────────────────────────────────────── // Mock stellar-sdk before importing the service @@ -85,7 +84,6 @@ describe('StellarService', () => { describe('constructor', () => { it('should default to testnet when STELLAR_NETWORK is not set', () => { - const { Networks } = require('stellar-sdk'); const service = createService({ STELLAR_NETWORK: '' }); // The service should use Networks.TESTNET as default @@ -154,7 +152,6 @@ describe('StellarService', () => { }); it('should use manageData operation with sanitized key', async () => { - const { Operation } = require('stellar-sdk'); service = createService(); await service.anchorHash('abc-123-def'); @@ -192,7 +189,6 @@ describe('StellarService', () => { }); it('should return true when the hash key exists in account data', async () => { - const { Operation } = require('stellar-sdk'); // Call anchorHash first to see what key name is generated, // then set it in the mock service = createService(); @@ -304,7 +300,6 @@ describe('StellarService', () => { }); it('should never use mainnet by default', () => { - const { TransactionBuilder } = require('stellar-sdk'); service = createService({ STELLAR_NETWORK: '' }); // anchorHash should work without errors using testnet diff --git a/backend/src/verification/verification.controller.spec.ts b/backend/src/verification/verification.controller.spec.ts index ca1ada10..5cec3ba1 100644 --- a/backend/src/verification/verification.controller.spec.ts +++ b/backend/src/verification/verification.controller.spec.ts @@ -5,7 +5,6 @@ import { VerificationController } from './verification.controller'; import { DocumentsService } from '../documents/documents.service'; import { VerificationService } from './verification.service'; import { - VerificationRecord, VerificationStatus, } from './entities/verification-record.entity'; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 95f5641c..f11ffe25 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "resolveJsonModule": true, "declaration": true, "removeComments": true, "emitDecoratorMetadata": true,