From ecb7b09618b001818d03a56234e387cc0143e38f Mon Sep 17 00:00:00 2001 From: Isaries Date: Mon, 6 Jul 2026 22:35:49 +0800 Subject: [PATCH 1/2] fix(security): show a message when password reset answers are throttled The server now temporarily blocks the student password reset flow after several incorrect security answers to prevent brute forcing the answer. The security answer step previously ignored any unrecognized response code, so a throttled user saw a blank error and no explanation. Handle the throttling response code in both the security answer and password change steps so the user is told to wait or to ask their teacher. --- .../forgot-student-password-change.component.ts | 3 +++ .../forgot-student-password-security.component.spec.ts | 5 +++++ .../forgot-student-password-security.component.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts index 17d73bd8a6b..bf29ca1d166 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts @@ -77,6 +77,9 @@ export class ForgotStudentPasswordChangeComponent { case 'invalidPassword': injectPasswordErrors(this.changePasswordFormGroup, error); break; + case 'tooManyFailedAnswerAttempts': + this.message = $localize`You have entered an incorrect answer too many times. Please wait a few minutes before trying again, or ask your teacher to change your password.`; + break; default: this.setErrorOccurredMessage(); } diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts index 8ca7e0ea6eb..d55c116b38c 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts @@ -87,6 +87,11 @@ async function changePassword() { expect(getErrorMessage()).toContain('Incorrect answer'); })); + it('should show the too many failed attempts message', waitForAsync(() => { + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'tooManyFailedAnswerAttempts'); + expect(getErrorMessage()).toContain('too many times'); + })); + it('should navigate to change password page', () => { const router = TestBed.inject(Router); const navigateSpy = spyOn(router, 'navigate'); diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts index 4f8b7cb83bf..0de04d5decc 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts @@ -99,6 +99,9 @@ export class ForgotStudentPasswordSecurityComponent { case 'incorrectAnswer': message = $localize`Incorrect answer, please try again. If you can't remember the answer to your security question, please ask your teacher to change your password or contact us for assistance.`; break; + case 'tooManyFailedAnswerAttempts': + message = $localize`You have entered an incorrect answer too many times. Please wait a few minutes before trying again, or ask your teacher to change your password.`; + break; case 'recaptchaResponseInvalid': message = $localize`Recaptcha failed. Please reload the page and try again.`; break; From a88793aa54dc8a09d13982bc6037b036ecf4e198 Mon Sep 17 00:00:00 2001 From: Isaries Date: Thu, 16 Jul 2026 16:55:06 +0800 Subject: [PATCH 2/2] fix(security): disable the form when password reset answers are throttled Showing only a message let a throttled student keep submitting answers. Mirror the teacher verification code flow: disable the form and show a link back to the start of the flow, with the warning and the link below the form where the teacher flow puts them. Unlike the teacher flow there is no new verification code for a student to generate, so the message tells them to wait and start again or to ask their teacher rather than promising the link will unblock them. The security answer step also had no default branch, so any response code it did not recognise left the message undefined and the student saw nothing at all. The server returns invalidUsername from that endpoint when the account has gone away mid-flow, which reached exactly that dead end. Both steps now share a base class holding the message state and the lockout, so the two copies of the response text cannot drift apart. --- ...tract-forgot-student-password.component.ts | 32 +++++++++++++++++++ ...got-student-password-change.component.html | 9 ++++-- ...-student-password-change.component.spec.ts | 29 ++++++++++++++++- ...orgot-student-password-change.component.ts | 23 ++++++------- ...t-student-password-security.component.html | 9 ++++-- ...tudent-password-security.component.spec.ts | 25 ++++++++++++++- ...got-student-password-security.component.ts | 27 ++++++++-------- 7 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 src/app/forgot/student/abstract-forgot-student-password.component.ts diff --git a/src/app/forgot/student/abstract-forgot-student-password.component.ts b/src/app/forgot/student/abstract-forgot-student-password.component.ts new file mode 100644 index 00000000000..08a56a1e1bb --- /dev/null +++ b/src/app/forgot/student/abstract-forgot-student-password.component.ts @@ -0,0 +1,32 @@ +import { Directive } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +@Directive() +export abstract class AbstractForgotStudentPasswordComponent { + protected message: string = ''; + protected processing: boolean = false; + protected showForgotPasswordLink: boolean = false; + + protected abstract getFormGroup(): FormGroup; + + /** + * The server temporarily blocks the reset after several incorrect security answers. Disabling + * the form stops the student from immediately trying again, and the link sends them back to the + * start of the flow. Unlike the teacher flow there is no new verification code to generate, so + * the message asks them to wait or to ask their teacher rather than promising the link unblocks + * them. + */ + protected tooManyFailedAnswerAttempts(): void { + this.message = $localize`You have entered an incorrect answer too many times. For security reasons, we will lock the ability to change your password for 10 minutes. After 10 minutes, please go back to the Forgot Student Password page to try again, or ask your teacher to change your password.`; + this.getFormGroup().disable(); + this.showForgotPasswordLink = true; + } + + protected setErrorOccurredMessage(): void { + this.message = $localize`An error occurred. Please try again.`; + } + + protected clearMessage(): void { + this.message = ''; + } +} diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html index 2ff0193d36a..eb895350d2e 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.html @@ -2,9 +2,6 @@

Change Password

- @if (message) { -

{{ message }}

- }

+

{{ message }}

+ @if (showForgotPasswordLink) { +

+ Forgot Student Password +

+ }
diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts index dee2dfb837b..aab33b0a27c 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.spec.ts @@ -3,7 +3,7 @@ import { ForgotStudentPasswordChangeComponent } from './forgot-student-password- import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { StudentService } from '../../../student/student.service'; import { provideRouter, Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; import { PasswordRequirementComponent } from '../../../password/password-requirement/password-requirement.component'; class MockStudentService { @@ -31,6 +31,15 @@ describe('ForgotStudentPasswordChangeComponent', () => { return fixture.debugElement.nativeElement.querySelector('button[type="submit"]'); }; + const getErrorMessage = () => { + const errorMessageDiv = fixture.debugElement.nativeElement.querySelector('.warn'); + return errorMessageDiv == null ? '' : errorMessageDiv.textContent; + }; + + const getForgotPasswordLink = () => { + return fixture.debugElement.nativeElement.querySelector('a[href="/forgot/student/password"]'); + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [BrowserAnimationsModule, ForgotStudentPasswordChangeComponent], @@ -60,6 +69,24 @@ describe('ForgotStudentPasswordChangeComponent', () => { expect(submitButton.disabled).toBe(false); }); + it('should disable the form and show the forgot password link when there are too many failed attempts', () => { + const password = PasswordRequirementComponent.VALID_PASSWORD; + component.changePasswordFormGroup.controls['newPassword'].setValue(password); + component.changePasswordFormGroup.controls['confirmNewPassword'].setValue(password); + fixture.detectChanges(); + expect(getSubmitButton().disabled).toBe(false); + const studentService = TestBed.inject(StudentService); + spyOn(studentService, 'changePassword').and.returnValue( + throwError(() => ({ error: { messageCode: 'tooManyFailedAnswerAttempts' } })) + ); + component.submit(); + fixture.detectChanges(); + expect(getErrorMessage()).toContain('too many times'); + expect(component.changePasswordFormGroup.controls['newPassword'].disabled).toBe(true); + expect(getSubmitButton().disabled).toBe(true); + expect(getForgotPasswordLink()).not.toBeNull(); + }); + it('should submit and navigate to the complete page', () => { const router = TestBed.inject(Router); const navigateSpy = spyOn(router, 'navigate'); diff --git a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts index bf29ca1d166..8cc0fec8f49 100644 --- a/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts +++ b/src/app/forgot/student/forgot-student-password-change/forgot-student-password-change.component.ts @@ -11,6 +11,7 @@ import { MatProgressBar } from '@angular/material/progress-bar'; import { MatButton } from '@angular/material/button'; import { PasswordModule } from '../../../password/password.module'; import { MatCard, MatCardContent } from '@angular/material/card'; +import { AbstractForgotStudentPasswordComponent } from '../abstract-forgot-student-password.component'; @Component({ templateUrl: './forgot-student-password-change.component.html', @@ -27,11 +28,9 @@ import { MatCard, MatCardContent } from '@angular/material/card'; RouterLink ] }) -export class ForgotStudentPasswordChangeComponent { +export class ForgotStudentPasswordChangeComponent extends AbstractForgotStudentPasswordComponent { @Input() answer: string; changePasswordFormGroup: FormGroup = this.fb.group({}); - protected message: string = ''; - protected processing: boolean = false; @Input() questionKey: string; @Input() username: string; @@ -40,7 +39,13 @@ export class ForgotStudentPasswordChangeComponent { private fb: FormBuilder, private router: Router, private studentService: StudentService - ) {} + ) { + super(); + } + + protected getFormGroup(): FormGroup { + return this.changePasswordFormGroup; + } ngAfterViewChecked(): void { this.changeDetectorRef.detectChanges(); @@ -78,7 +83,7 @@ export class ForgotStudentPasswordChangeComponent { injectPasswordErrors(this.changePasswordFormGroup, error); break; case 'tooManyFailedAnswerAttempts': - this.message = $localize`You have entered an incorrect answer too many times. Please wait a few minutes before trying again, or ask your teacher to change your password.`; + this.tooManyFailedAnswerAttempts(); break; default: this.setErrorOccurredMessage(); @@ -99,14 +104,6 @@ export class ForgotStudentPasswordChangeComponent { return this.changePasswordFormGroup.get(fieldName).value; } - private setErrorOccurredMessage(): void { - this.message = $localize`An error occurred. Please try again.`; - } - - private clearMessage(): void { - this.message = ''; - } - private goToSuccessPage(): void { const params = { username: this.username diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html index 8139431fa63..b073ca26e32 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.html @@ -2,9 +2,6 @@

Answer Security Question

- @if (message) { -

{{ message }}

- }

{{ question }} @@ -44,6 +41,12 @@

Answer Security Question

+

{{ message }}

+ @if (showForgotPasswordLink) { +

+ Forgot Student Password +

+ }
diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts index d55c116b38c..ae5f291091b 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.spec.ts @@ -92,6 +92,21 @@ async function changePassword() { expect(getErrorMessage()).toContain('too many times'); })); + it('should disable the form and show the forgot password link when there are too many failed attempts', waitForAsync(() => { + component.setControlFieldValue('answer', 'cookies'); + fixture.detectChanges(); + expect(getSubmitButton().disabled).toBe(false); + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'tooManyFailedAnswerAttempts'); + expect(getAnswerInput().disabled).toBe(true); + expect(getSubmitButton().disabled).toBe(true); + expect(getForgotPasswordLink()).not.toBeNull(); + })); + + it('should show the error occurred message for an unrecognized response code', waitForAsync(() => { + submitAndReceiveResponse('checkSecurityAnswer', 'failure', 'invalidUsername'); + expect(getErrorMessage()).toContain('An error occurred'); + })); + it('should navigate to change password page', () => { const router = TestBed.inject(Router); const navigateSpy = spyOn(router, 'navigate'); @@ -147,9 +162,17 @@ function createObservableResponse(status, messageCode) { function getErrorMessage() { const errorMessageDiv = fixture.debugElement.nativeElement.querySelector('.warn'); - return errorMessageDiv.textContent; + return errorMessageDiv == null ? '' : errorMessageDiv.textContent; } function getSubmitButton() { return fixture.debugElement.nativeElement.querySelector('button[type="submit"]'); } + +function getAnswerInput() { + return fixture.debugElement.nativeElement.querySelector('#answer'); +} + +function getForgotPasswordLink() { + return fixture.debugElement.nativeElement.querySelector('a[href="/forgot/student/password"]'); +} diff --git a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts index 0de04d5decc..05c4df2143f 100644 --- a/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts +++ b/src/app/forgot/student/forgot-student-password-security/forgot-student-password-security.component.ts @@ -18,6 +18,7 @@ import { MatButton } from '@angular/material/button'; import { MatInput } from '@angular/material/input'; import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; import { MatCard, MatCardContent } from '@angular/material/card'; +import { AbstractForgotStudentPasswordComponent } from '../abstract-forgot-student-password.component'; @Component({ templateUrl: './forgot-student-password-security.component.html', @@ -38,14 +39,12 @@ import { MatCard, MatCardContent } from '@angular/material/card'; RecaptchaV3Module ] }) -export class ForgotStudentPasswordSecurityComponent { +export class ForgotStudentPasswordSecurityComponent extends AbstractForgotStudentPasswordComponent { protected answer: string; protected answerSecurityQuestionFormGroup: FormGroup = this.fb.group({ answer: new FormControl('', [Validators.required]) }); isRecaptchaEnabled: boolean = this.configService.isRecaptchaEnabled(); - protected message: string; - protected processing: boolean = false; @Input() question: string; @Input() questionKey: string; @Input() username: string; @@ -56,7 +55,13 @@ export class ForgotStudentPasswordSecurityComponent { private recaptchaV3Service: ReCaptchaV3Service, private router: Router, private studentService: StudentService - ) {} + ) { + super(); + } + + protected getFormGroup(): FormGroup { + return this.answerSecurityQuestionFormGroup; + } async submit() { this.processing = true; @@ -94,19 +99,19 @@ export class ForgotStudentPasswordSecurityComponent { } securityAnswerError(response: any): void { - let message; switch (response.messageCode) { case 'incorrectAnswer': - message = $localize`Incorrect answer, please try again. If you can't remember the answer to your security question, please ask your teacher to change your password or contact us for assistance.`; + this.message = $localize`Incorrect answer, please try again. If you can't remember the answer to your security question, please ask your teacher to change your password or contact us for assistance.`; break; case 'tooManyFailedAnswerAttempts': - message = $localize`You have entered an incorrect answer too many times. Please wait a few minutes before trying again, or ask your teacher to change your password.`; + this.tooManyFailedAnswerAttempts(); break; case 'recaptchaResponseInvalid': - message = $localize`Recaptcha failed. Please reload the page and try again.`; + this.message = $localize`Recaptcha failed. Please reload the page and try again.`; break; + default: + this.setErrorOccurredMessage(); } - this.message = message; } getAnswer() { @@ -120,8 +125,4 @@ export class ForgotStudentPasswordSecurityComponent { setControlFieldValue(name: string, value: string): void { this.answerSecurityQuestionFormGroup.controls[name].setValue(value); } - - private clearMessage(): void { - this.message = ''; - } }