diff --git a/backend/src/users/dto/update-consent.dto.ts b/backend/src/users/dto/update-consent.dto.ts new file mode 100644 index 0000000..3456a81 --- /dev/null +++ b/backend/src/users/dto/update-consent.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class UpdateConsentDto { + @IsString() + @IsNotEmpty() + termsVersion!: string; + + @IsString() + @IsNotEmpty() + privacyVersion!: string; +} diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..7a4a0af --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class UpdateUserDto { + @IsOptional() + @IsString() + @MaxLength(32) + displayName?: string; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts new file mode 100644 index 0000000..04b596b --- /dev/null +++ b/backend/src/users/users.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { AuthService } from "../auth/auth.service"; +import { Roles } from "../auth/decorators/roles.decorator"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { RolesGuard } from "../auth/guards/roles.guard"; +import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; +import { UpdateConsentDto } from "./dto/update-consent.dto"; +import { UpdateUserDto } from "./dto/update-user.dto"; +import { UsersService } from "./users.service"; + +@Controller("users") +export class UsersController { + constructor( + private readonly usersService: UsersService, + private readonly authService: AuthService, + ) {} + + // Gestion administrative des utilisateurs + @Get("admin") + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + findAll( + @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, + @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + return this.usersService.findAll(limit, offset); + } + + // Listing public d'un profil + @Get("public/:username") + findPublicProfile(@Param("username") username: string) { + return this.usersService.findPublicProfile(username); + } + + // Gestion de son propre compte + @Get("me") + @UseGuards(AuthGuard) + findMe(@Req() req: AuthenticatedRequest) { + return this.usersService.findOneWithPrivateData(req.user.sub); + } + + @Get("me/export") + @UseGuards(AuthGuard) + exportMe(@Req() req: AuthenticatedRequest) { + return this.usersService.exportUserData(req.user.sub); + } + + @Patch("me") + @UseGuards(AuthGuard) + updateMe( + @Req() req: AuthenticatedRequest, + @Body() updateUserDto: UpdateUserDto, + ) { + return this.usersService.update(req.user.sub, updateUserDto); + } + + @Patch("me/consent") + @UseGuards(AuthGuard) + updateConsent( + @Req() req: AuthenticatedRequest, + @Body() consentDto: UpdateConsentDto, + ) { + return this.usersService.updateConsent( + req.user.sub, + consentDto.termsVersion, + consentDto.privacyVersion, + ); + } + + @Delete("me") + @UseGuards(AuthGuard) + removeMe(@Req() req: AuthenticatedRequest) { + return this.usersService.remove(req.user.sub); + } + + // Double Authentification (2FA) + @Post("me/2fa/setup") + @UseGuards(AuthGuard) + setup2fa(@Req() req: AuthenticatedRequest) { + return this.authService.generateTwoFactorSecret(req.user.sub); + } + + @Post("me/2fa/enable") + @UseGuards(AuthGuard) + enable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) { + return this.authService.enableTwoFactor(req.user.sub, token); + } + + @Post("me/2fa/disable") + @UseGuards(AuthGuard) + disable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) { + return this.authService.disableTwoFactor(req.user.sub, token); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 0000000..c78bc42 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { CryptoModule } from "../crypto/crypto.module"; +import { DatabaseModule } from "../database/database.module"; +import { UsersController } from "./users.controller"; +import { UsersService } from "./users.service"; + +@Module({ + imports: [DatabaseModule, CryptoModule, AuthModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 0000000..bfc7e7f --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,224 @@ +import { Injectable } from "@nestjs/common"; +import { eq, sql } from "drizzle-orm"; +import { CryptoService } from "../crypto/crypto.service"; +import { DatabaseService } from "../database/database.service"; +import { contents, favorites, users } from "../database/schemas"; +import { UpdateUserDto } from "./dto/update-user.dto"; + +@Injectable() +export class UsersService { + constructor( + private readonly databaseService: DatabaseService, + private readonly cryptoService: CryptoService, + ) {} + + async create(data: { + username: string; + email: string; + passwordHash: string; + emailHash: string; + }) { + const pgpKey = this.cryptoService.getPgpEncryptionKey(); + + const [newUser] = await this.databaseService.db + .insert(users) + .values({ + username: data.username, + email: sql`pgp_sym_encrypt(${data.email}, ${pgpKey})`, + emailHash: data.emailHash, + passwordHash: data.passwordHash, + }) + .returning(); + + return newUser; + } + + async findByEmailHash(emailHash: string) { + const pgpKey = this.cryptoService.getPgpEncryptionKey(); + + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + email: sql`pgp_sym_decrypt(${users.email}, ${pgpKey})`, + passwordHash: users.passwordHash, + status: users.status, + isTwoFactorEnabled: users.isTwoFactorEnabled, + }) + .from(users) + .where(eq(users.emailHash, emailHash)) + .limit(1); + + return result[0] || null; + } + + async findOneWithPrivateData(uuid: string) { + const pgpKey = this.cryptoService.getPgpEncryptionKey(); + + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + email: sql`pgp_sym_decrypt(${users.email}, ${pgpKey})`, + displayName: users.displayName, + status: users.status, + isTwoFactorEnabled: users.isTwoFactorEnabled, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + + return result[0] || null; + } + + async findAll(limit: number, offset: number) { + const totalCountResult = await this.databaseService.db + .select({ count: sql`count(*)` }) + .from(users); + + const totalCount = Number(totalCountResult[0].count); + + const data = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + displayName: users.displayName, + status: users.status, + createdAt: users.createdAt, + }) + .from(users) + .limit(limit) + .offset(offset); + + return { data, totalCount }; + } + + async findPublicProfile(username: string) { + const result = await this.databaseService.db + .select({ + uuid: users.uuid, + username: users.username, + displayName: users.displayName, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + return result[0] || null; + } + + async findOne(uuid: string) { + const result = await this.databaseService.db + .select() + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + + return result[0] || null; + } + + async update(uuid: string, data: UpdateUserDto) { + return await this.databaseService.db + .update(users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(users.uuid, uuid)) + .returning(); + } + + async updateConsent( + uuid: string, + termsVersion: string, + privacyVersion: string, + ) { + return await this.databaseService.db + .update(users) + .set({ + termsVersion, + privacyVersion, + gdprAcceptedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(users.uuid, uuid)) + .returning(); + } + + async setTwoFactorSecret(uuid: string, secret: string) { + const pgpKey = this.cryptoService.getPgpEncryptionKey(); + return await this.databaseService.db + .update(users) + .set({ + twoFactorSecret: sql`pgp_sym_encrypt(${secret}, ${pgpKey})`, + updatedAt: new Date(), + }) + .where(eq(users.uuid, uuid)) + .returning(); + } + + async toggleTwoFactor(uuid: string, enabled: boolean) { + return await this.databaseService.db + .update(users) + .set({ + isTwoFactorEnabled: enabled, + updatedAt: new Date(), + }) + .where(eq(users.uuid, uuid)) + .returning(); + } + + async getTwoFactorSecret(uuid: string): Promise { + const pgpKey = this.cryptoService.getPgpEncryptionKey(); + const result = await this.databaseService.db + .select({ + secret: sql`pgp_sym_decrypt(${users.twoFactorSecret}, ${pgpKey})`, + }) + .from(users) + .where(eq(users.uuid, uuid)) + .limit(1); + + return result[0]?.secret || null; + } + + async exportUserData(uuid: string) { + const user = await this.findOneWithPrivateData(uuid); + if (!user) return null; + + const userContents = await this.databaseService.db + .select() + .from(contents) + .where(eq(contents.userId, uuid)); + + const userFavorites = await this.databaseService.db + .select() + .from(favorites) + .where(eq(favorites.userId, uuid)); + + return { + profile: user, + contents: userContents, + favorites: userFavorites, + exportedAt: new Date(), + }; + } + + async remove(uuid: string) { + return await this.databaseService.db.transaction(async (tx) => { + // Soft delete de l'utilisateur + const userResult = await tx + .update(users) + .set({ status: "deleted", deletedAt: new Date() }) + .where(eq(users.uuid, uuid)) + .returning(); + + // Soft delete de tous ses contenus + await tx + .update(contents) + .set({ deletedAt: new Date() }) + .where(eq(contents.userId, uuid)); + + return userResult; + }); + } +}