feat(auth): verifie le lien de reset des le chargement, sans le consommer
Ajoute GET /auth/reset-password/validate (lecture seule, sans rate limit : le jeton est un secret de 256 bits non brute-forcable) pour que la page reset-password redirige immediatement vers /login si le lien est invalide ou expire, plutot que d'attendre la soumission du formulaire. La verification a la soumission (confirm_password_reset) reste la seule source de verite atomique.
This commit is contained in:
@@ -83,4 +83,17 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toEqual(tokenResponse.principal);
|
||||
});
|
||||
|
||||
it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => {
|
||||
let result: { valid: boolean } | undefined;
|
||||
service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque`
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({ valid: true });
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,4 +83,10 @@ export class AuthService {
|
||||
.post<TokenResponse>(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true })
|
||||
.pipe(tap((response) => this.setSession(response)));
|
||||
}
|
||||
|
||||
validateResetToken(token: string): Observable<{ valid: boolean }> {
|
||||
return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, {
|
||||
params: { token },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<h1>Nouveau mot de passe</h1>
|
||||
|
||||
@if (hasToken) {
|
||||
@if (hasToken && !isCheckingToken()) {
|
||||
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
|
||||
|
||||
<label for="new_password">Nouveau mot de passe</label>
|
||||
@@ -23,6 +23,10 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (hasToken && isCheckingToken()) {
|
||||
<p class="auth-subtitle">Vérification du lien...</p>
|
||||
}
|
||||
|
||||
<p class="auth-link"><a routerLink="/forgot-password">Redemander un lien</a></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,13 @@ function configure(token: string | null) {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [ResetPassword, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { resetPassword: vi.fn() } },
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
resetPassword: vi.fn(),
|
||||
validateResetToken: vi.fn().mockReturnValue(of({ valid: true })),
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: { navigate: vi.fn() } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
@@ -36,6 +42,32 @@ describe('ResetPassword', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('vérifie le jeton sans le consommer dès le chargement de la page', async () => {
|
||||
await configure('un-secret-opaque');
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const auth = TestBed.inject(AuthService) as unknown as { validateResetToken: ReturnType<typeof vi.fn> };
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(auth.validateResetToken).toHaveBeenCalledWith('un-secret-opaque');
|
||||
expect(fixture.componentInstance.isCheckingToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('redirige immédiatement vers /login si la vérification signale un jeton invalide', async () => {
|
||||
await configure('un-secret-perime');
|
||||
TestBed.overrideProvider(AuthService, {
|
||||
useValue: { resetPassword: vi.fn(), validateResetToken: vi.fn().mockReturnValue(of({ valid: false })) },
|
||||
});
|
||||
const fixture = TestBed.createComponent(ResetPassword);
|
||||
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
|
||||
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -33,11 +33,23 @@ export class ResetPassword implements OnInit {
|
||||
});
|
||||
|
||||
password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||
isCheckingToken = signal(this.hasToken);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.hasToken) {
|
||||
this.redirigeVersLoginLienInvalide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.validateResetToken(this.token).subscribe({
|
||||
next: ({ valid }) => {
|
||||
this.isCheckingToken.set(false);
|
||||
if (!valid) {
|
||||
this.redirigeVersLoginLienInvalide();
|
||||
}
|
||||
},
|
||||
error: () => this.isCheckingToken.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
|
||||
Reference in New Issue
Block a user