diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 636d7e5..5ba8cbe 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -236,10 +236,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', ); } @@ -271,21 +271,21 @@ 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, + email, fullName, this.getSessionContext(req), ); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 4507702..2b40a0e 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { TokenRevocationService } from './token-revocation.service'; import { TwoFactorService } from './two-factor.service'; import { RefreshSession } from './entities/refresh-session.entity'; import { UsersModule } from '../users/users.module'; @@ -33,6 +34,7 @@ import { GithubStrategy } from './strategies/github.strategy'; controllers: [AuthController], providers: [ AuthService, + TokenRevocationService, TwoFactorService, JwtStrategy, GoogleStrategy, diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 448c435..fe821d3 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -1,183 +1,221 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; -import { AuthService } from './auth.service'; -import { UsersService } from '../users/users.service'; -import { ConflictException, UnauthorizedException } from '@nestjs/common'; - -const mockUser = { - id: 'user-1', - email: 'test@example.com', - passwordHash: '$2b$12$hashedpassword', - fullName: 'Test User', - role: 'user' as const, - isVerified: true, -}; - -const mockUsersService = { - findByEmail: jest.fn(), - create: jest.fn(), - findById: jest.fn(), - update: jest.fn(), - changeEmail: jest.fn(), -}; - -const mockJwtService = { - signAsync: jest.fn().mockResolvedValue('mock-token'), - verifyAsync: jest.fn(), - decode: jest.fn(), -}; - -describe('AuthService', () => { - let service: AuthService; - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - { provide: UsersService, useValue: mockUsersService }, - { provide: JwtService, useValue: mockJwtService }, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - if (key === 'JWT_SECRET') return 'test-secret'; - return undefined; - }), - }, - }, - ], - }).compile(); - - service = module.get(AuthService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('handleOAuthLogin()', () => { - it('should create a new user if no existing user with same email', async () => { - mockUsersService.findByEmail.mockResolvedValue(null); - mockUsersService.create.mockResolvedValue({ - ...mockUser, - passwordHash: null, - }); - - const result = await service.handleOAuthLogin( - 'new@example.com', - 'New User', - ); - expect(mockUsersService.create).toHaveBeenCalledWith({ - email: 'new@example.com', - fullName: 'New User', - passwordHash: null, - role: 'user', - isVerified: true, - }); - expect(result.access_token).toBe('mock-token'); - }); - - it('should link OAuth to existing OAuth-only account (no password)', async () => { - const existingOAuthUser = { ...mockUser, passwordHash: null }; - mockUsersService.findByEmail.mockResolvedValue(existingOAuthUser); - - const result = await service.handleOAuthLogin( - 'test@example.com', - 'Test User', - ); - expect(result.access_token).toBe('mock-token'); - expect(mockUsersService.create).not.toHaveBeenCalled(); - }); - - it('should reject OAuth login if email matches a password-based account', async () => { - mockUsersService.findByEmail.mockResolvedValue(mockUser); - - await expect( - service.handleOAuthLogin('test@example.com', 'Test User'), - ).rejects.toThrow(ConflictException); - }); - - it('should throw BadRequestException if no email is provided', async () => { - await expect( - service.handleOAuthLogin('', 'Test User'), - ).rejects.toThrow('Email is required'); - }); - }); - - describe('OAuth exchange codes', () => { - it('consumes a code exactly once', async () => { - const code = await service.createOAuthExchangeCode({ - access_token: 'access-token', - refresh_token: 'refresh-token', - }); - - await expect(service.exchangeOAuthCode(code)).resolves.toEqual({ - access_token: 'access-token', - refresh_token: 'refresh-token', - }); - await expect(service.exchangeOAuthCode(code)).rejects.toThrow( - UnauthorizedException, - ); - }); - }); - - describe('logout()', () => { - it('should add a verified token to the blacklist', async () => { - mockJwtService.verifyAsync.mockResolvedValueOnce({ - sub: 'user-1', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - - await service.logout('test-token'); - expect(service.isTokenBlacklisted('test-token')).toBe(true); - }); - - it('revokes both distinct verified access credentials', async () => { - mockJwtService.verifyAsync.mockResolvedValue({ - sub: 'user-1', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - - await service.logout( - ['access-token-a', 'access-token-b'], - 'refresh-token', - ); - - expect(service.isTokenBlacklisted('access-token-a')).toBe(true); - expect(service.isTokenBlacklisted('access-token-b')).toBe(true); - }); - - it('continues revoking after a malformed access credential', async () => { - mockJwtService.verifyAsync.mockImplementation(async (token: string) => { - if (token === 'bad-access-token') throw new Error('invalid'); - return { - sub: 'user-1', - exp: Math.floor(Date.now() / 1000) + 3600, - }; - }); - - await service.logout( - ['bad-access-token', 'good-access-token'], - 'refresh-token', - ); - - expect(service.isTokenBlacklisted('good-access-token')).toBe(true); - expect(service.isTokenBlacklisted('bad-access-token')).toBe(false); - }); - - it('should handle malformed tokens gracefully', async () => { - mockJwtService.verifyAsync.mockRejectedValueOnce(new Error('invalid')); - - await expect(service.logout('bad-token')).resolves.toBeUndefined(); - expect(service.isTokenBlacklisted('bad-token')).toBe(false); - }); - }); - - describe('isTokenBlacklisted()', () => { - it('should return false for non-blacklisted tokens', () => { - expect(service.isTokenBlacklisted('nonexistent-token')).toBe(false); - }); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { RefreshSession } from './entities/refresh-session.entity'; +import { TwoFactorService } from './two-factor.service'; +import { TokenRevocationService } from './token-revocation.service'; + +const mockUser = { + id: 'user-1', + email: 'test@example.com', + passwordHash: '$2b$12$hashedpassword', + fullName: 'Test User', + role: 'user' as const, + isVerified: true, +}; + +const mockUsersService = { + findByEmail: jest.fn(), + create: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + changeEmail: jest.fn(), +}; + +const mockJwtService = { + signAsync: jest.fn().mockResolvedValue('mock-token'), + verifyAsync: jest.fn(), + decode: jest.fn(), +}; + +const revokedJtis = new Set(); +const mockRevocationService = { + revoke: jest.fn(async (jti: string) => { + revokedJtis.add(jti); + }), + isRevoked: jest.fn(async (jti?: string) => !!jti && revokedJtis.has(jti)), +}; + +const mockSessionsRepository = { + save: jest.fn(async (session: object) => ({ id: 'session-1', ...session })), + create: jest.fn((session: object) => session), + update: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), +}; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + jest.clearAllMocks(); + revokedJtis.clear(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: UsersService, useValue: mockUsersService }, + { provide: JwtService, useValue: mockJwtService }, + { + provide: getRepositoryToken(RefreshSession), + useValue: mockSessionsRepository, + }, + { provide: TwoFactorService, useValue: { challenge: jest.fn() } }, + { provide: TokenRevocationService, useValue: mockRevocationService }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'JWT_SECRET') return 'test-secret'; + return undefined; + }), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleOAuthLogin()', () => { + it('should create a new user if no existing user with same email', async () => { + mockUsersService.findByEmail.mockResolvedValue(null); + mockUsersService.create.mockResolvedValue({ + ...mockUser, + passwordHash: null, + }); + + const result = await service.handleOAuthLogin( + 'new@example.com', + 'New User', + ); + expect(mockUsersService.create).toHaveBeenCalledWith({ + email: 'new@example.com', + fullName: 'New User', + passwordHash: null, + role: 'user', + isVerified: true, + }); + expect(result.access_token).toBe('mock-token'); + }); + + it('should link OAuth to existing OAuth-only account (no password)', async () => { + const existingOAuthUser = { ...mockUser, passwordHash: null }; + mockUsersService.findByEmail.mockResolvedValue(existingOAuthUser); + + const result = await service.handleOAuthLogin( + 'test@example.com', + 'Test User', + ); + expect(result.access_token).toBe('mock-token'); + expect(mockUsersService.create).not.toHaveBeenCalled(); + }); + + it('should reject OAuth login if email matches a password-based account', async () => { + mockUsersService.findByEmail.mockResolvedValue(mockUser); + + await expect( + service.handleOAuthLogin('test@example.com', 'Test User'), + ).rejects.toThrow(ConflictException); + }); + + it('should throw BadRequestException if no email is provided', async () => { + await expect( + service.handleOAuthLogin('', 'Test User'), + ).rejects.toThrow('Email is required'); + }); + }); + + describe('OAuth exchange codes', () => { + it('consumes a code exactly once', async () => { + const code = await service.createOAuthExchangeCode({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }); + + await expect(service.exchangeOAuthCode(code)).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }); + await expect(service.exchangeOAuthCode(code)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('logout()', () => { + const exp = () => Math.floor(Date.now() / 1000) + 3600; + + it('should revoke the jti of a verified token', async () => { + mockJwtService.verifyAsync.mockResolvedValueOnce({ + sub: 'user-1', + jti: 'jti-1', + exp: exp(), + }); + + await service.logout('test-token'); + await expect(service.isTokenBlacklisted('jti-1')).resolves.toBe(true); + }); + + it('revokes both distinct verified access credentials', async () => { + mockJwtService.verifyAsync.mockImplementation(async (token: string) => ({ + sub: 'user-1', + jti: `jti-${token}`, + exp: exp(), + })); + + await service.logout( + ['access-token-a', 'access-token-b'], + 'refresh-token', + ); + + await expect( + service.isTokenBlacklisted('jti-access-token-a'), + ).resolves.toBe(true); + await expect( + service.isTokenBlacklisted('jti-access-token-b'), + ).resolves.toBe(true); + }); + + it('continues revoking after a malformed access credential', async () => { + mockJwtService.verifyAsync.mockImplementation(async (token: string) => { + if (token === 'bad-access-token') throw new Error('invalid'); + return { sub: 'user-1', jti: `jti-${token}`, exp: exp() }; + }); + + await service.logout( + ['bad-access-token', 'good-access-token'], + 'refresh-token', + ); + + await expect( + service.isTokenBlacklisted('jti-good-access-token'), + ).resolves.toBe(true); + await expect( + service.isTokenBlacklisted('jti-bad-access-token'), + ).resolves.toBe(false); + }); + + it('should handle malformed tokens gracefully', async () => { + mockJwtService.verifyAsync.mockRejectedValueOnce(new Error('invalid')); + + await expect(service.logout('bad-token')).resolves.toBeUndefined(); + expect(mockRevocationService.revoke).not.toHaveBeenCalled(); + }); + }); + + describe('isTokenBlacklisted()', () => { + it('should return false for non-revoked tokens', async () => { + await expect(service.isTokenBlacklisted('nonexistent-jti')).resolves.toBe( + false, + ); + }); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index e7dd57e..ed44cc4 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -10,6 +10,7 @@ import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; +import { randomUUID } from 'crypto'; import { UsersService } from '../users/users.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; @@ -19,6 +20,7 @@ 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'; +import { TokenRevocationService } from './token-revocation.service'; export interface SessionContext { userAgent?: string; @@ -32,10 +34,6 @@ export interface TwoFactorRequired { @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, @@ -43,6 +41,7 @@ export class AuthService { @InjectRepository(RefreshSession) private readonly sessionsRepository: Repository, private readonly twoFactorService: TwoFactorService, + private readonly tokenRevocationService: TokenRevocationService, ) {} async register(dto: RegisterAuthDto, context?: SessionContext) { @@ -141,10 +140,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, @@ -153,6 +148,10 @@ 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'); @@ -169,7 +168,7 @@ export class AuthService { await this.sessionsRepository.save(session); } - await this.revokeRefreshToken(refreshToken, payload); + await this.revokeRefreshToken(payload); return this.issueTokens(user, context); } catch { throw new UnauthorizedException('Invalid refresh token'); @@ -187,16 +186,18 @@ 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); } listSessions(userId: string): Promise { @@ -277,28 +278,26 @@ export class AuthService { 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 { + let payload: JwtPayload; + try { + payload = await this.jwtService.verifyAsync(token, { + secret: this.getRefreshSecret(), + }); + } catch { + return; } - if (!payload) return; + await this.revokeRefreshToken(payload); + } + + private async revokeRefreshToken(payload: JwtPayload): Promise { if (payload.sid) { await this.sessionsRepository.update( { id: payload.sid }, @@ -306,24 +305,16 @@ export class AuthService { ); } - this.rememberToken( - this.refreshTokenBlacklist, - token, - this.getPayloadTtl(payload), - ); + 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 { @@ -355,6 +346,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role, + jti: randomUUID(), }; return this.jwtService.signAsync(payload); @@ -366,6 +358,7 @@ export class AuthService { email: user.email, role: user.role, sid: sessionId, + jti: randomUUID(), }; return this.jwtService.signAsync(payload, { diff --git a/backend/src/auth/interfaces/jwt-payload.interface.ts b/backend/src/auth/interfaces/jwt-payload.interface.ts index 7f3d647..f0acee3 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; sid?: string; exp?: number; iat?: number; diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 6bda1dc..811bb14 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 0000000..7651a7b --- /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 8f26a69..dea2499 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 0000000..76f553d --- /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 4dd78f1..303ac9a 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -23,12 +23,25 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; import { User, UserRole } from './entities/user.entity'; +import { UpdateProfileDto } from './dto/update-profile.dto'; import { ListUsersDto } from './dto/list-users.dto'; import { AdminUpdateUserDto } from './dto/admin-update-user.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; type AuthedRequest = Request & { user?: User }; +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, + }; +} + function toPublicUser(user: User) { return { id: user.id, @@ -58,6 +71,29 @@ export class UsersController { private readonly accessLogsService: AccessLogsService, ) {} + @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(ApiKeyAuthGuard) async exportMyData(@Req() req: AuthedRequest) {