From add7cab7dfaea5368124449f5dbec55d05f65b6e Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:27:20 +0100 Subject: [PATCH] feat: implement UsersModule with service, controller, and DTOs Added UsersModule to manage user-related operations. Includes UsersService for CRUD operations, consent updates, and 2FA handling. Implemented UsersController with endpoints for public profiles, account management, and admin user listing. Integrated with CryptoService and database schemas. --- backend/src/users/dto/update-consent.dto.ts | 11 + backend/src/users/dto/update-user.dto.ts | 8 + backend/src/users/users.controller.ts | 107 ++++++++++ backend/src/users/users.module.ts | 14 ++ backend/src/users/users.service.ts | 224 ++++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 backend/src/users/dto/update-consent.dto.ts create mode 100644 backend/src/users/dto/update-user.dto.ts create mode 100644 backend/src/users/users.controller.ts create mode 100644 backend/src/users/users.module.ts create mode 100644 backend/src/users/users.service.ts 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; + }); + } +}