diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index a68e74e..2d7942d 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { RegisterAmResponseDto } from './dto/register-am-response.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; +import { CreatePasswordDto } from './dto/create-password.dto'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; @@ -109,6 +110,29 @@ export class AuthController { return this.authService.refreshTokens(dto.refresh_token); } + @Public() + @Get('verify-token') + @ApiOperation({ summary: 'Vérifier un token de création de mot de passe' }) + @ApiQuery({ name: 'token', required: true, description: 'Token UUID reçu par email' }) + @ApiResponse({ status: 200, description: 'Token valide' }) + @ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' }) + async verifyCreatePasswordToken(@Query('token') token: string) { + return this.authService.verifyCreatePasswordToken(token); + } + + @Public() + @Post('create-password') + @ApiOperation({ summary: 'Créer le mot de passe initial via token email' }) + @ApiResponse({ status: 201, description: 'Mot de passe créé avec succès' }) + @ApiResponse({ status: 400, description: 'Confirmation invalide ou mot de passe invalide' }) + @ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' }) + async createPassword(@Body() dto: CreatePasswordDto) { + if (dto.password !== dto.password_confirmation) { + throw new BadRequestException('Les mots de passe ne correspondent pas'); + } + return this.authService.createPasswordWithToken(dto.token, dto.password); + } + @Get('me') @UseGuards(AuthGuard) @ApiBearerAuth('access-token') diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 86d11c9..bced215 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -137,6 +137,116 @@ export class AuthService { } } + async verifyCreatePasswordToken(token: string) { + const cleanedToken = token?.trim(); + if (!cleanedToken) { + throw new BadRequestException('Token manquant'); + } + + const user = await this.usersRepo.findOne({ + where: { token_creation_mdp: cleanedToken }, + select: ['id', 'token_creation_mdp_expire_le', 'password'], + }); + + if (!user) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + if (user.password) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + if (!user.token_creation_mdp_expire_le || user.token_creation_mdp_expire_le <= new Date()) { + // Invalidation explicite d'un token expiré pour éviter toute réutilisation ultérieure. + await this.usersRepo + .createQueryBuilder() + .update(Users) + .set({ + token_creation_mdp: () => 'NULL', + token_creation_mdp_expire_le: () => 'NULL', + }) + .where('id = :id', { id: user.id }) + .execute(); + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + return { + valid: true, + message: 'Token valide', + }; + } + + async createPasswordWithToken(token: string, plainPassword: string) { + const cleanedToken = token?.trim(); + if (!cleanedToken) { + throw new BadRequestException('Token manquant'); + } + + const existingUser = await this.usersRepo.findOne({ + where: { + token_creation_mdp: cleanedToken, + }, + select: ['id', 'password', 'token_creation_mdp_expire_le'], + }); + + if (!existingUser) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + if (existingUser.password) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + if ( + !existingUser.token_creation_mdp_expire_le || + existingUser.token_creation_mdp_expire_le <= new Date() + ) { + await this.usersRepo + .createQueryBuilder() + .update(Users) + .set({ + token_creation_mdp: () => 'NULL', + token_creation_mdp_expire_le: () => 'NULL', + }) + .where('id = :id', { id: existingUser.id }) + .execute(); + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + const sel = await bcrypt.genSalt(12); + const hashedPassword = await bcrypt.hash(plainPassword, sel); + + // Usage unique atomique : une seule requête UPDATE avec garde-fous. + const updateResult = await this.usersRepo + .createQueryBuilder() + .update(Users) + .set({ + password: hashedPassword, + token_creation_mdp: () => 'NULL', + token_creation_mdp_expire_le: () => 'NULL', + statut: StatutUtilisateurType.ACTIF, + changement_mdp_obligatoire: false, + }) + .where('token_creation_mdp = :token', { token: cleanedToken }) + .andWhere('token_creation_mdp_expire_le > NOW()') + .andWhere('password IS NULL') + .execute(); + + if (!updateResult.affected) { + this.logger.warn( + '[createPasswordWithToken] Token non consommé (course concurrente ou token invalidé)', + ); + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + this.logger.log('[createPasswordWithToken] Mot de passe initial créé avec succès'); + + return { + message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.', + userId: existingUser.id, + }; + } + /** * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * @deprecated diff --git a/backend/src/routes/auth/dto/create-password.dto.ts b/backend/src/routes/auth/dto/create-password.dto.ts new file mode 100644 index 0000000..367a5c4 --- /dev/null +++ b/backend/src/routes/auth/dto/create-password.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, Matches } from 'class-validator'; + +export class CreatePasswordDto { + @ApiProperty({ description: 'Token de création de mot de passe reçu par email' }) + @IsString() + token: string; + + @ApiProperty({ + description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)', + minLength: 8, + }) + @IsString() + @MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' }) + @Matches(/^(?=.*[A-Z])(?=.*\d)/, { + message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre', + }) + password: string; + + @ApiProperty({ description: 'Confirmation du nouveau mot de passe' }) + @IsString() + password_confirmation: string; +}