From c2f360c5919355fb6201899b351a5c21c2a378f5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 14:42:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20ne=20plus=20redemander=20le=20mot?= =?UTF-8?q?=20de=20passe=20provisoire=20=C3=A0=20la=20premi=C3=A8re=20conn?= =?UTF-8?q?exion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran de changement imposé redemandait le mot de passe provisoire qui venait d'être vérifié, sans champ identifiant. Un gestionnaire de mots de passe y collait un ancien mot de passe du site : /auth/password répondait 401 « Identifiants invalides », et le message unique accusait aussi la politique de mot de passe. Constaté en rec et en dev sur les comptes nominatifs. - AuthService garde en mémoire le mot de passe d'une connexion qui impose le changement, rendu une seule fois par takeProvisionalPassword() et effacé avec la session. - Le champ « Mot de passe actuel » ne s'affiche que si ce mot de passe manque (page rechargée) ou vient d'être refusé. - Champ identifiant masqué pour les gestionnaires de mots de passe. - Messages distincts pour 401, 422 et le reste, liste des critères en direct. --- .../app/core/services/auth.service.spec.ts | 25 ++++ .../src/app/core/services/auth.service.ts | 19 ++- .../auth/change-password/change-password.html | 22 +-- .../change-password/change-password.spec.ts | 126 +++++++++++++----- .../auth/change-password/change-password.ts | 36 +++-- tests/e2e/specs/premiere-connexion.spec.ts | 2 +- 6 files changed, 180 insertions(+), 50 deletions(-) diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts index c51c8eb..aa3244c 100644 --- a/apps/frontend/src/app/core/services/auth.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -43,6 +43,31 @@ describe('AuthService', () => { expect(service.isAuthenticated()).toBe(true); }); + it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => { + service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({ + ...tokenResponse, + principal: { ...tokenResponse.principal, must_change_password: true }, + }); + + expect(service.takeProvisionalPassword()).toBe('Provisoire'); + expect(service.takeProvisionalPassword()).toBeNull(); + }); + + it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => { + service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse); + expect(service.takeProvisionalPassword()).toBeNull(); + + service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({ + ...tokenResponse, + principal: { ...tokenResponse.principal, must_change_password: true }, + }); + service.clearSession(); + expect(service.takeProvisionalPassword()).toBeNull(); + }); + it('efface la session au logout', () => { service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index c2d3e9c..5441349 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -19,6 +19,9 @@ export class AuthService { // mémoire. Un rechargement de page le perd, c'est voulu par le contrat. private accessTokenSignal = signal(null); private principalSignal = signal(null); + // Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire + // de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401. + private provisionalPassword: string | null = null; readonly principal = this.principalSignal.asReadonly(); readonly isAuthenticated = computed(() => this.principalSignal() !== null); @@ -37,12 +40,26 @@ export class AuthService { clearSession(): void { this.accessTokenSignal.set(null); this.principalSignal.set(null); + this.provisionalPassword = null; } login(credentials: LoginRequest): Observable { return this.http .post(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true }) - .pipe(tap((response) => this.setSession(response))); + .pipe( + tap((response) => { + this.setSession(response); + this.provisionalPassword = response.principal.must_change_password + ? credentials.password + : null; + }) + ); + } + + takeProvisionalPassword(): string | null { + const password = this.provisionalPassword; + this.provisionalPassword = null; + return password; } // Un seul rafraîchissement en vol à la fois, partagé entre tous les diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index 6c3de32..2777d4e 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -7,14 +7,18 @@ Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + + @if (asksCurrentPassword()) { + + + } - {{ passwordHint }} + @if (errorMessage()) { {{ errorMessage() }} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 126e892..3b340c0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -1,17 +1,29 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { signal } from '@angular/core'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { ChangePassword } from './change-password'; import { AuthService } from '../../../core/services/auth.service'; +const NOUVEAU = 'Un-nouveau-mot-de-passe1!'; + describe('ChangePassword', () => { - let authMock: { changePassword: ReturnType }; + let authMock: { + changePassword: ReturnType; + takeProvisionalPassword: ReturnType; + principal: ReturnType; + }; let routerMock: { navigate: ReturnType }; beforeEach(async () => { - authMock = { changePassword: vi.fn() }; + authMock = { + changePassword: vi.fn(), + takeProvisionalPassword: vi.fn().mockReturnValue(null), + principal: signal({ email: 'johan@enervision.fr' }), + }; routerMock = { navigate: vi.fn() }; await TestBed.configureTestingModule({ @@ -23,6 +35,10 @@ describe('ChangePassword', () => { }).compileComponents(); }); + function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null { + return fixture.nativeElement.querySelector('#current_password'); + } + it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; @@ -44,7 +60,7 @@ describe('ChangePassword', () => { it('redirige vers /dashboard après un changement réussi', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU }); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -52,46 +68,94 @@ describe('ChangePassword', () => { expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); }); - it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { - const fixture = TestBed.createComponent(ChangePassword); - const component = fixture.componentInstance; - component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); + it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => { + const fixture = TestBed.createComponent(ChangePassword); + fixture.detectChanges(); - authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); + expect(champActuel(fixture)).not.toBeNull(); + }); - component.onSubmit(); - fixture.detectChanges(); // rend le bloc @if (errorMessage()) + it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => { + authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres'); + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + fixture.detectChanges(); - expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.ev-alert'); - expect(errorEl?.textContent).toContain('incorrect'); + expect(champActuel(fixture)).toBeNull(); + component.form.controls.new_password.setValue(NOUVEAU); + component.onSubmit(); + + expect(authMock.changePassword).toHaveBeenCalledWith({ + current_password: 'Provisoire-24-caracteres', + new_password: NOUVEAU, + }); + }); + + it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => { + const fixture = TestBed.createComponent(ChangePassword); + fixture.detectChanges(); + + const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]'); + expect(identifiant.value).toBe('johan@enervision.fr'); + }); + + it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => { + authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime'); + authMock.changePassword.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 401 })), + ); + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.controls.new_password.setValue(NOUVEAU); + + component.onSubmit(); + fixture.detectChanges(); + + expect(component.errorMessage()).toContain('Mot de passe actuel incorrect'); + expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect'); + expect(champActuel(fixture)).not.toBeNull(); + expect(component.form.controls.current_password.value).toBe(''); + }); + + it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => { + authMock.changePassword.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 422 })), + ); + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU }); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('Nouveau mot de passe refusé'); + expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe'); }); it('désactive le bouton tant que le formulaire est invalide', () => { - const fixture = TestBed.createComponent(ChangePassword); - fixture.detectChanges(); + const fixture = TestBed.createComponent(ChangePassword); + fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('button[type="submit"]'); - expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); + const button = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(button.disabled).toBe(true); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { - const fixture = TestBed.createComponent(ChangePassword); - const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); - fixture.detectChanges(); + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU }); + fixture.detectChanges(); - authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); - const form = fixture.nativeElement.querySelector('form'); - form.dispatchEvent(new Event('submit')); - fixture.detectChanges(); + const form = fixture.nativeElement.querySelector('form'); + form.dispatchEvent(new Event('submit')); + fixture.detectChanges(); - expect(authMock.changePassword).toHaveBeenCalledWith({ - current_password: 'ancien-mot-de-passe', - new_password: 'Un-nouveau-mot-de-passe1!', + expect(authMock.changePassword).toHaveBeenCalledWith({ + current_password: 'ancien-mot-de-passe', + new_password: NOUVEAU, + }); }); }); - -}); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 06be74d..662b1e9 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -1,17 +1,20 @@ import { Component, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; import { Button } from '../../../shared/components/ui/button/button'; import { Card } from '../../../shared/components/ui/card/card'; import { Alert } from '../../../shared/components/ui/alert/alert'; import { Brand } from '../../../shared/components/ui/brand/brand'; +import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule, Button, Card, Alert, Brand], + imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) @@ -20,30 +23,47 @@ export class ChangePassword { private auth = inject(AuthService); private router = inject(Router); + private provisionalPassword = this.auth.takeProvisionalPassword(); + errorMessage = signal(null); isLoading = signal(false); - passwordHint = PASSWORD_HINT; + asksCurrentPassword = signal(this.provisionalPassword === null); + email = this.auth.principal()?.email ?? ''; form = this.fb.nonNullable.group({ - current_password: ['', Validators.required], + current_password: [this.provisionalPassword ?? '', Validators.required], new_password: ['', passwordValidators], }); + newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' }); + onSubmit(): void { if (this.form.invalid) return; this.isLoading.set(true); this.errorMessage.set(null); this.auth.changePassword(this.form.getRawValue()).subscribe({ - next: (response) => { + next: () => { this.router.navigate(['/dashboard']); }, - error: () => { + error: (error: HttpErrorResponse) => { this.isLoading.set(false); - this.errorMessage.set( - `Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`, - ); + this.errorMessage.set(this.explique(error)); + if (error.status === 401) { + this.form.controls.current_password.reset(''); + this.asksCurrentPassword.set(true); + } }, }); } + + private explique(error: HttpErrorResponse): string { + if (error.status === 401) { + return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.'; + } + if (error.status === 422) { + return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`; + } + return 'Le changement de mot de passe a échoué, réessayez dans un instant.'; + } } diff --git a/tests/e2e/specs/premiere-connexion.spec.ts b/tests/e2e/specs/premiere-connexion.spec.ts index 3f34783..5e894bb 100644 --- a/tests/e2e/specs/premiere-connexion.spec.ts +++ b/tests/e2e/specs/premiere-connexion.spec.ts @@ -12,7 +12,7 @@ test('impose le changement du mot de passe temporaire avant le tableau de bord', await expect(page).toHaveURL(/\/change-password$/); await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible(); - await page.getByLabel('Mot de passe actuel').fill(compte.password); + await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0); await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse()); await page.getByRole('button', { name: 'Valider' }).click();