From ae82fb74259215e7040cc2cd7065f48924ca68b0 Mon Sep 17 00:00:00 2001 From: oladev2026-tech Date: Sat, 17 Oct 2026 09:35:17 +0000 Subject: [PATCH] fix(backend): run analyze job, add profile routes, verified OAuth emails, shared revocations Closes #1479 Closes #1469 Closes #1463 Closes #1461 --- backend/src/auth/auth.controller.ts | 21 ++--- backend/src/auth/auth.module.ts | 9 +- backend/src/auth/auth.service.ts | 79 ++++++++--------- .../auth/interfaces/jwt-payload.interface.ts | 1 + backend/src/auth/strategies/jwt.strategy.ts | 7 +- backend/src/auth/token-revocation.service.ts | 84 +++++++++++++++++++ backend/src/queue/document.processor.ts | 5 +- backend/src/users/dto/update-profile.dto.ts | 13 +++ backend/src/users/users.controller.ts | 46 +++++++++- 9 files changed, 197 insertions(+), 68 deletions(-) create mode 100644 backend/src/auth/token-revocation.service.ts create mode 100644 backend/src/users/dto/update-profile.dto.ts diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 088bcc35..000798b2 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -142,10 +142,10 @@ export class AuthController { @Res() res: Response, ) { const profile = req.user; - const email = profile?.emails?.[0]?.value; + const email = profile?.emails?.find((entry) => entry.verified)?.value; if (!email) { throw new BadRequestException( - 'Google profile did not contain an email address', + 'Google account has no verified email address', ); } @@ -176,23 +176,20 @@ export class AuthController { @Res() res: Response, ) { const profile = req.user; - const githubId = profile?.id?.toString(); - const email = profile?.emails?.[0]?.value; - const identifier = email || (githubId ? `github:${githubId}` : null); - if (!identifier) { - throw new BadRequestException('GitHub profile could not be identified'); + const email = profile?.emails?.find((entry) => entry.verified)?.value; + if (!email) { + throw new BadRequestException( + 'GitHub account has no verified email address', + ); } const fullName = this.buildFullName([ profile?.displayName, profile?.username, - ]) || identifier; + ]) || email; - const tokens = await this.authService.handleOAuthLogin( - identifier, - fullName, - ); + const tokens = await this.authService.handleOAuthLogin(email, fullName); return this.redirectWithCookies(tokens, res); } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 95967f20..95802d82 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { TokenRevocationService } from './token-revocation.service'; import { UsersModule } from '../users/users.module'; import { JwtStrategy } from './strategies/jwt.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; @@ -27,7 +28,13 @@ import { GithubStrategy } from './strategies/github.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, GoogleStrategy, GithubStrategy], + providers: [ + AuthService, + TokenRevocationService, + 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..fe935edc 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -7,6 +7,7 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { randomUUID } from 'crypto'; import { UsersService } from '../users/users.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; @@ -14,17 +15,15 @@ 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 { TokenRevocationService } from './token-revocation.service'; @Injectable() export class AuthService { - private readonly tokenBlacklist = new Set(); - private readonly refreshTokenBlacklist = new Set(); - private readonly maxBlacklistEntries = 10000; - constructor( private readonly usersService: UsersService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly tokenRevocationService: TokenRevocationService, ) {} async register(dto: RegisterAuthDto) { @@ -89,10 +88,6 @@ export class AuthService { throw new BadRequestException('Refresh token is required'); } - if (this.refreshTokenBlacklist.has(refreshToken)) { - throw new UnauthorizedException('Invalid refresh token'); - } - try { const payload = await this.jwtService.verifyAsync( refreshToken, @@ -101,12 +96,16 @@ export class AuthService { }, ); + if (await this.tokenRevocationService.isRevoked(payload.jti)) { + throw new UnauthorizedException('Invalid refresh token'); + } + const user = await this.usersService.findById(payload.sub); if (!user) { throw new UnauthorizedException('Invalid refresh token'); } - await this.revokeRefreshToken(refreshToken, payload); + await this.revokeRefreshToken(payload); const access_token = await this.generateAccessToken(user); const refresh_token = await this.generateRefreshToken(user); return { access_token, refresh_token }; @@ -126,61 +125,51 @@ export class AuthService { : []; const distinctAccessTokens = [...new Set(accessTokens.filter(Boolean))]; - await Promise.allSettled( + // No allSettled here: a failed revocation must surface instead of + // silently leaving a token usable. + await Promise.all( distinctAccessTokens.map((token) => this.blacklistAccessToken(token)), ); if (refreshToken) { - await Promise.allSettled([this.revokeRefreshToken(refreshToken)]); + await this.revokeRefreshTokenFromToken(refreshToken); } } - isTokenBlacklisted(token: string): boolean { - return this.tokenBlacklist.has(token); + isTokenBlacklisted(jti?: string): Promise { + return this.tokenRevocationService.isRevoked(jti); } private async blacklistAccessToken(token: string): Promise { try { const payload = await this.jwtService.verifyAsync(token); - this.rememberToken(this.tokenBlacklist, token, this.getPayloadTtl(payload)); + await this.revokePayload(payload); } catch { return; } } - private async revokeRefreshToken( - token: string, - verifiedPayload?: JwtPayload, - ): Promise { - let payload = verifiedPayload; - if (!payload) { - try { - payload = await this.jwtService.verifyAsync(token, { - secret: this.getRefreshSecret(), - }); - } catch { - return; - } + private async revokeRefreshTokenFromToken(token: string): Promise { + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.getRefreshSecret(), + }); + await this.revokePayload(payload); + } catch { + return; } - if (!payload) return; + } - this.rememberToken( - this.refreshTokenBlacklist, - token, - this.getPayloadTtl(payload), - ); + private async revokeRefreshToken(payload: JwtPayload): Promise { + await this.revokePayload(payload); } - private rememberToken( - blacklist: Set, - token: string, - ttl: number | null, - ): void { - if (ttl === null || ttl <= 0 || blacklist.has(token)) return; + private async revokePayload(payload: JwtPayload): Promise { + if (!payload.jti) return; - if (blacklist.size >= this.maxBlacklistEntries) return; + const ttl = this.getPayloadTtl(payload); + if (ttl === null) return; - blacklist.add(token); - setTimeout(() => blacklist.delete(token), ttl * 1000); + await this.tokenRevocationService.revoke(payload.jti, ttl); } private getPayloadTtl(payload: JwtPayload): number | null { @@ -212,6 +201,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role, + jti: randomUUID(), }; return this.jwtService.signAsync(payload); @@ -222,6 +212,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role, + jti: randomUUID(), }; return this.jwtService.signAsync(payload, { @@ -240,8 +231,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/interfaces/jwt-payload.interface.ts b/backend/src/auth/interfaces/jwt-payload.interface.ts index 37e2e2ed..218898e1 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; + jti?: string; exp?: number; iat?: number; } diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 6bda1dcb..811bb145 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -31,14 +31,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(request: any, payload?: JwtPayload) { - const tokenRequest = payload ? request : undefined; const tokenPayload = payload ?? (request as JwtPayload); - const token = - tokenRequest && tokenRequest.headers - ? (accessTokenExtractor(tokenRequest as Request) as string | null) - : null; - if (token && this.authService.isTokenBlacklisted(token)) { + if (await this.authService.isTokenBlacklisted(tokenPayload.jti)) { throw new UnauthorizedException('Token has been revoked'); } diff --git a/backend/src/auth/token-revocation.service.ts b/backend/src/auth/token-revocation.service.ts new file mode 100644 index 00000000..7651a7bd --- /dev/null +++ b/backend/src/auth/token-revocation.service.ts @@ -0,0 +1,84 @@ +import { + Injectable, + OnModuleDestroy, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +const KEY_PREFIX = 'revoked:token:'; + +/** + * Revocations live in Redis so they survive restarts and are shared between + * instances. Every lookup fails closed: if the store cannot be reached the + * request is rejected instead of silently accepting a revoked token. + */ +@Injectable() +export class TokenRevocationService implements OnModuleDestroy { + private client: Redis | null = null; + private connection: Promise | null = null; + + constructor(private readonly configService: ConfigService) {} + + async revoke(jti: string, ttlSeconds: number): Promise { + if (!jti || ttlSeconds <= 0) return; + + const client = await this.getClient(); + try { + await client.set(`${KEY_PREFIX}${jti}`, '1', 'EX', ttlSeconds); + } catch { + throw new ServiceUnavailableException( + 'Token revocation store is unavailable', + ); + } + } + + async isRevoked(jti?: string): Promise { + if (!jti) return false; + + const client = await this.getClient(); + try { + return (await client.exists(`${KEY_PREFIX}${jti}`)) === 1; + } catch { + throw new ServiceUnavailableException( + 'Token revocation store is unavailable', + ); + } + } + + async onModuleDestroy(): Promise { + if (this.client) { + await this.client.quit().catch(() => undefined); + this.client = null; + this.connection = null; + } + } + + private getClient(): Promise { + if (!this.connection) { + const client = new Redis({ + host: this.configService.get('REDIS_HOST') || '127.0.0.1', + port: Number(this.configService.get('REDIS_PORT') || '6379'), + password: + this.configService.get('REDIS_PASSWORD') || undefined, + lazyConnect: true, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + }); + + this.connection = client + .connect() + .then(() => { + this.client = client; + return client; + }) + .catch((error) => { + this.connection = null; + client.disconnect(); + throw error; + }); + } + + return this.connection; + } +} diff --git a/backend/src/queue/document.processor.ts b/backend/src/queue/document.processor.ts index 8f26a69f..dea2499f 100644 --- a/backend/src/queue/document.processor.ts +++ b/backend/src/queue/document.processor.ts @@ -13,6 +13,7 @@ import { DocumentStatus } from '../documents/entities/document.entity'; import { VerificationService } from '../verification/verification.service'; import { VerificationStatus } from '../verification/entities/verification-record.entity'; import { RiskAssessmentService } from '../risk-assessment/risk-assessment.service'; +import { getRiskThresholds } from '../risk-assessment/risk-thresholds.config'; import { StellarService } from '../stellar/stellar.service'; import { QueueService, DocumentJobData } from './queue.service'; import { @@ -47,7 +48,7 @@ export class DocumentProcessor implements OnModuleDestroy { ); if (job.name === 'analyze') { - await this.riskService.assessDocument(data.documentId); + await this.handleAnalyze(data.documentId); return; } if (job.name === 'anchor') { @@ -92,7 +93,7 @@ export class DocumentProcessor implements OnModuleDestroy { const result = await this.riskService.assessDocument(documentId); let newStatus: DocumentStatus; - if (result.score >= 60) { + if (result.score >= getRiskThresholds().medium) { newStatus = DocumentStatus.FLAGGED; } else { newStatus = DocumentStatus.VERIFIED; diff --git a/backend/src/users/dto/update-profile.dto.ts b/backend/src/users/dto/update-profile.dto.ts new file mode 100644 index 00000000..76f553df --- /dev/null +++ b/backend/src/users/dto/update-profile.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, Length } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @Length(1, 120) + fullName?: string; + + @IsOptional() + @IsString() + @Length(2, 8) + preferredLanguage?: string; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index ce867896..ef7b3440 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,9 +1,30 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + NotFoundException, + Patch, + Req, + UseGuards, +} from '@nestjs/common'; import { Request } from 'express'; import { UsersService } from './users.service'; import { DocumentsService } from '../documents/documents.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { User } from './entities/user.entity'; +import { UpdateProfileDto } from './dto/update-profile.dto'; + +function toProfile(user: User) { + return { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + isVerified: user.isVerified, + preferredLanguage: user.preferredLanguage, + twoFactorEnabled: user.twoFactorEnabled, + }; +} /** * Self-service data export for GDPR-style portability requests. @@ -18,6 +39,29 @@ export class UsersController { private readonly documentsService: DocumentsService, ) {} + @Get('me') + @UseGuards(JwtAuthGuard) + async getMyProfile(@Req() req: Request & { user?: User }) { + const user = await this.usersService.findById(req.user!.id); + if (!user) { + throw new NotFoundException('User not found'); + } + return toProfile(user); + } + + @Patch('me') + @UseGuards(JwtAuthGuard) + async updateMyProfile( + @Req() req: Request & { user?: User }, + @Body() dto: UpdateProfileDto, + ) { + const updated = await this.usersService.update(req.user!.id, dto); + if (!updated) { + throw new NotFoundException('User not found'); + } + return toProfile(updated); + } + @Get('me/export') @UseGuards(JwtAuthGuard) async exportMyData(@Req() req: Request & { user?: User }) {