feat(auth): politique de complexite du mot de passe et flux de reinitialisation

Remplace la regle de longueur seule (12 caracteres) par une exigence de
composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere
special), non documentee dans les exigences officielles du projet, par une
regle explicite partagee entre le backend (validateur Pydantic) et le
frontend.

Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici :
jeton a usage unique hache en base (meme principe que les refresh tokens),
expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib,
Mailpit en dev), avec limitation de debit dediee et reponse generique pour
eviter l'enumeration des comptes.

Closes #87
This commit is contained in:
Johan LEROY
2026-09-17 10:53:58 +02:00
parent 3692d486c6
commit 9161b74874
48 changed files with 1914 additions and 25 deletions
+2
View File
@@ -5,6 +5,8 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{
path: 'dashboard',
canActivate: [authGuard],
@@ -1,7 +1,14 @@
import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs';
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model';
import {
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
Principal,
ResetPasswordRequest,
TokenResponse,
} from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment';
@Service()
@@ -66,4 +73,14 @@ export class AuthService {
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
forgotPassword(payload: ForgotPasswordRequest): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/forgot-password`, payload);
}
resetPassword(payload: ResetPasswordRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
}
@@ -18,7 +18,7 @@
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
<span class="auth-hint">{{ passwordHint }}</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
@@ -32,10 +32,19 @@ describe('ChangePassword', () => {
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
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-passe-valide' });
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -46,7 +55,7 @@ describe('ChangePassword', () => {
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-passe-valide' });
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
@@ -70,7 +79,7 @@ describe('ChangePassword', () => {
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-passe-valide' });
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -81,7 +90,7 @@ describe('ChangePassword', () => {
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'un-nouveau-mot-de-passe-valide',
new_password: 'Un-nouveau-mot-de-passe1!',
});
});
@@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-change-password',
@@ -17,10 +18,11 @@ export class ChangePassword {
errorMessage = signal<string | null>(null);
isLoading = signal(false);
passwordHint = PASSWORD_HINT;
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
new_password: ['', passwordValidators],
});
onSubmit(): void {
@@ -34,7 +36,7 @@ export class ChangePassword {
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`);
},
});
}
@@ -0,0 +1,37 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Mot de passe oublié</h1>
<p class="auth-subtitle">Recevez un lien de réinitialisation par email</p>
@if (submitted()) {
<p class="auth-success">
Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé.
Il expire dans 15 minutes.
</p>
} @else {
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Envoi...' : 'Envoyer le lien' }}
</button>
}
<p class="auth-link"><a routerLink="/login">Retour à la connexion</a></p>
</form>
</div>
@@ -0,0 +1,104 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-success {
margin: 0.75rem 0 0;
color: #16a34a;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ForgotPassword } from './forgot-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ForgotPassword', () => {
let authMock: { forgotPassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { forgotPassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ForgotPassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{ provide: ActivatedRoute, useValue: {} },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ForgotPassword);
fixture.componentInstance.onSubmit();
expect(authMock.forgotPassword).not.toHaveBeenCalled();
});
it('affiche le message générique après une soumission réussie', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(of(undefined));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'inconnu@enervision.fr' });
authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le délai à respecter quand le taux limite est atteint', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 429,
headers: new HttpHeaders({ 'Retry-After': '900' }),
})
)
);
component.onSubmit();
expect(component.submitted()).toBe(false);
expect(component.retryAfterSeconds()).toBe(900);
});
});
@@ -0,0 +1,53 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './forgot-password.html',
styleUrl: './forgot-password.scss',
})
export class ForgotPassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
submitted = signal(false);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.forgotPassword(this.form.getRawValue()).subscribe({
// Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse
// du serveur est déjà générique, l'écran doit l'être aussi.
next: () => {
this.isLoading.set(false);
this.submitted.set(true);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de demandes, réessayez plus tard.');
return;
}
this.submitted.set(true);
},
});
}
}
@@ -32,5 +32,7 @@
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
</form>
</div>
@@ -79,3 +79,13 @@
color: #dc2626;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
@@ -20,6 +20,7 @@ describe('Login', () => {
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{ provide: ActivatedRoute, useValue: {} },
],
}).compileComponents();
});
@@ -1,13 +1,13 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './login.html',
styleUrl: './login.scss',
})
@@ -0,0 +1,30 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
@if (!hasToken) {
<p class="auth-error">Ce lien est incomplet. Redemandez un lien de réinitialisation.</p>
} @else {
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">{{ passwordHint }}</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
}
<p class="auth-link"><a routerLink="/forgot-password">Redemander un lien</a></p>
</form>
</div>
@@ -0,0 +1,104 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-success {
margin: 0.75rem 0 0;
color: #16a34a;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ResetPassword } from './reset-password';
import { AuthService } from '../../../core/services/auth.service';
function configure(token: string | null) {
return TestBed.configureTestingModule({
imports: [ResetPassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: { resetPassword: vi.fn() } },
{ provide: Router, useValue: { navigate: vi.fn() } },
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } },
},
],
}).compileComponents();
}
describe('ResetPassword', () => {
it("signale un lien incomplet quand le jeton est absent de l'URL", async () => {
await configure(null);
const fixture = TestBed.createComponent(ResetPassword);
expect(fixture.componentInstance.hasToken).toBe(false);
});
it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'trop-simple' });
component.onSubmit();
expect(auth.resetPassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après une réinitialisation réussie', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } }));
component.onSubmit();
expect(auth.resetPassword).toHaveBeenCalledWith({
token: 'un-secret-opaque',
new_password: 'Un-nouveau-mot-de-passe1!',
});
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('affiche un message dédié quand le lien est invalide ou expiré', async () => {
await configure('un-secret-perime');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
component.onSubmit();
expect(component.errorMessage()).toContain('invalide');
});
});
@@ -0,0 +1,52 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-reset-password',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './reset-password.html',
styleUrl: './reset-password.scss',
})
export class ResetPassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private token = this.route.snapshot.queryParamMap.get('token') ?? '';
errorMessage = signal<string | null>(null);
isLoading = signal(false);
passwordHint = PASSWORD_HINT;
hasToken = this.token.length > 0;
form = this.fb.nonNullable.group({
new_password: ['', passwordValidators],
});
onSubmit(): void {
if (this.form.invalid || !this.hasToken) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 400) {
this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.');
return;
}
this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`);
},
});
}
}
@@ -10,6 +10,15 @@ export interface PasswordChangeRequest {
new_password: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
new_password: string;
}
export interface Principal {
id: string;
email: string;
@@ -0,0 +1,15 @@
import { Validators } from '@angular/forms';
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 128;
export const PASSWORD_HINT =
'8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial';
const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/;
export const passwordValidators = [
Validators.required,
Validators.minLength(PASSWORD_MIN_LENGTH),
Validators.maxLength(PASSWORD_MAX_LENGTH),
Validators.pattern(PASSWORD_PATTERN),
];