fix(auth): ne plus redemander le mot de passe provisoire à la première connexion
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.
This commit is contained in:
@@ -43,6 +43,31 @@ describe('AuthService', () => {
|
|||||||
expect(service.isAuthenticated()).toBe(true);
|
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', () => {
|
it('efface la session au logout', () => {
|
||||||
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
||||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export class AuthService {
|
|||||||
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
||||||
private accessTokenSignal = signal<string | null>(null);
|
private accessTokenSignal = signal<string | null>(null);
|
||||||
private principalSignal = signal<Principal | null>(null);
|
private principalSignal = signal<Principal | null>(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 principal = this.principalSignal.asReadonly();
|
||||||
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
||||||
@@ -37,12 +40,26 @@ export class AuthService {
|
|||||||
clearSession(): void {
|
clearSession(): void {
|
||||||
this.accessTokenSignal.set(null);
|
this.accessTokenSignal.set(null);
|
||||||
this.principalSignal.set(null);
|
this.principalSignal.set(null);
|
||||||
|
this.provisionalPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
login(credentials: LoginRequest): Observable<TokenResponse> {
|
login(credentials: LoginRequest): Observable<TokenResponse> {
|
||||||
return this.http
|
return this.http
|
||||||
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
.post<TokenResponse>(`${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
|
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||||
|
|
||||||
|
@if (asksCurrentPassword()) {
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||||
<input
|
<input
|
||||||
id="current_password"
|
id="current_password"
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
formControlName="current_password"
|
formControlName="current_password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
||||||
<input
|
<input
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
formControlName="new_password"
|
formControlName="new_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
<span class="form-hint">{{ passwordHint }}</span>
|
<app-password-requirements [password]="newPassword()" />
|
||||||
|
|
||||||
@if (errorMessage()) {
|
@if (errorMessage()) {
|
||||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { ChangePassword } from './change-password';
|
import { ChangePassword } from './change-password';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
|
||||||
|
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
|
||||||
|
|
||||||
describe('ChangePassword', () => {
|
describe('ChangePassword', () => {
|
||||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
let authMock: {
|
||||||
|
changePassword: ReturnType<typeof vi.fn>;
|
||||||
|
takeProvisionalPassword: ReturnType<typeof vi.fn>;
|
||||||
|
principal: ReturnType<typeof signal>;
|
||||||
|
};
|
||||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(async () => {
|
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() };
|
routerMock = { navigate: vi.fn() };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
|
|||||||
}).compileComponents();
|
}).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)', () => {
|
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
|
|||||||
it('redirige vers /dashboard après un changement réussi', () => {
|
it('redirige vers /dashboard après un changement réussi', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
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' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
|
||||||
@@ -52,19 +68,68 @@ describe('ChangePassword', () => {
|
|||||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
|
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();
|
||||||
|
|
||||||
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
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();
|
component.onSubmit();
|
||||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.errorMessage()).toContain('incorrect');
|
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||||
expect(errorEl?.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', () => {
|
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||||
@@ -79,7 +144,7 @@ describe('ChangePassword', () => {
|
|||||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
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 });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
@@ -90,8 +155,7 @@ describe('ChangePassword', () => {
|
|||||||
|
|
||||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||||
current_password: 'ancien-mot-de-passe',
|
current_password: 'ancien-mot-de-passe',
|
||||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
new_password: NOUVEAU,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { Button } from '../../../shared/components/ui/button/button';
|
import { Button } from '../../../shared/components/ui/button/button';
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
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';
|
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-change-password',
|
selector: 'app-change-password',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
|
||||||
templateUrl: './change-password.html',
|
templateUrl: './change-password.html',
|
||||||
styleUrl: './change-password.scss',
|
styleUrl: './change-password.scss',
|
||||||
})
|
})
|
||||||
@@ -20,30 +23,47 @@ export class ChangePassword {
|
|||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private provisionalPassword = this.auth.takeProvisionalPassword();
|
||||||
|
|
||||||
errorMessage = signal<string | null>(null);
|
errorMessage = signal<string | null>(null);
|
||||||
isLoading = signal(false);
|
isLoading = signal(false);
|
||||||
passwordHint = PASSWORD_HINT;
|
asksCurrentPassword = signal(this.provisionalPassword === null);
|
||||||
|
email = this.auth.principal()?.email ?? '';
|
||||||
|
|
||||||
form = this.fb.nonNullable.group({
|
form = this.fb.nonNullable.group({
|
||||||
current_password: ['', Validators.required],
|
current_password: [this.provisionalPassword ?? '', Validators.required],
|
||||||
new_password: ['', passwordValidators],
|
new_password: ['', passwordValidators],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||||
next: (response) => {
|
next: () => {
|
||||||
this.router.navigate(['/dashboard']);
|
this.router.navigate(['/dashboard']);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
this.errorMessage.set(
|
this.errorMessage.set(this.explique(error));
|
||||||
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
|
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.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).toHaveURL(/\/change-password$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
|
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.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
||||||
await page.getByRole('button', { name: 'Valider' }).click();
|
await page.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user