Skip to content

Commit d6281fa

Browse files
authored
Make TOTP verification more robust (#272)
1 parent 8b57097 commit d6281fa

File tree

3 files changed

+40
-40
lines changed

3 files changed

+40
-40
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This repo uses `pnpm` (not npm) for dependency installation and scripts.
5656
- Use `computed()` for derived state
5757
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
5858
- Prefer inline templates for small components
59-
- Prefer Reactive forms instead of Template-driven ones
59+
- Prefer signal forms over reactive and template-driven ones
6060
- Do NOT use `ngClass`, use `class` bindings instead
6161
- Do NOT use `ngStyle`, use `style` bindings instead
6262

src/app/auth/features/confirm-totp/confirm-totp.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@ <h2>Two-factor authentication</h2>
55
</p>
66

77
<form
8+
novalidate
89
defaultButton="submit-auth"
910
class="mb-3 flex flex-wrap items-center gap-2"
10-
(ngSubmit)="onSubmit()"
11+
(submit)="onSubmit($event)"
1112
>
1213
<mat-form-field class="grow sm:grow-0">
1314
<mat-label>Authentication code</mat-label>
1415
<input
1516
type="text"
17+
inputmode="numeric"
1618
class="totp-input-style text-center sm:text-left"
1719
matInput
18-
[formControl]="codeControl"
20+
[formField]="totpForm.code"
1921
(input)="onInput($event)"
2022
autocomplete="one-time-code"
2123
aria-required="true"
@@ -47,5 +49,5 @@ <h2>Two-factor authentication</h2>
4749
</p>
4850

4951
@if (verificationError()) {
50-
<mat-error>The submitted authentication code is invalid.</mat-error>
52+
<mat-error class="mt-6">The submitted authentication code is invalid.</mat-error>
5153
}

src/app/auth/features/confirm-totp/confirm-totp.ts

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,7 @@
55
*/
66

77
import { Component, computed, inject, signal } from '@angular/core';
8-
import { toSignal } from '@angular/core/rxjs-interop';
9-
import {
10-
FormControl,
11-
FormsModule,
12-
ReactiveFormsModule,
13-
Validators,
14-
} from '@angular/forms';
8+
import { form, FormField, maxLength, pattern, required } from '@angular/forms/signals';
159
import { MatButtonModule } from '@angular/material/button';
1610
import { MatFormFieldModule } from '@angular/material/form-field';
1711
import { MatInputModule } from '@angular/material/input';
@@ -23,63 +17,67 @@ import { NotificationService } from '@app/shared/services/notification';
2317
*/
2418
@Component({
2519
selector: 'app-confirm-totp',
26-
imports: [
27-
FormsModule,
28-
ReactiveFormsModule,
29-
MatButtonModule,
30-
MatFormFieldModule,
31-
MatInputModule,
32-
],
20+
imports: [FormField, MatButtonModule, MatFormFieldModule, MatInputModule],
3321
templateUrl: './confirm-totp.html',
3422
styleUrl: './confirm-totp.scss',
3523
})
3624
export class ConfirmTotpComponent {
3725
#notify = inject(NotificationService);
3826
#authService = inject(AuthService);
3927

40-
protected disabled = computed(
41-
() => this.#validInput() !== 'VALID' || this.#isProcessing(),
42-
);
28+
protected totpModel = signal<{ code: string }>({
29+
code: '',
30+
});
4331

44-
#previousSubmission: string | undefined = undefined;
32+
protected totpForm = form(this.totpModel, (schemaPath) => {
33+
required(schemaPath.code);
34+
maxLength(schemaPath.code, 6);
35+
pattern(schemaPath.code, /^\d{6}$/);
36+
});
4537

46-
#isProcessing = signal(false);
38+
#previousSubmission = signal('');
4739

48-
protected codeControl = new FormControl<string>('', [
49-
Validators.required,
50-
Validators.pattern(/^\d{6}$/),
51-
]);
52-
#validInput = toSignal(this.codeControl.statusChanges, {
53-
initialValue: this.codeControl.status,
54-
});
40+
#isProcessing = signal(false);
5541

5642
protected verificationError = signal(false);
5743

5844
allowNavigation = false; // used by canDeactivate guard
5945

46+
protected disabled = computed<boolean>(
47+
() =>
48+
!this.totpForm.code().value() ||
49+
this.totpForm.code().invalid() ||
50+
this.#isProcessing() ||
51+
this.totpForm.code().value() === this.#previousSubmission(),
52+
);
53+
6054
/**
6155
* Input handler for the TOTP code
6256
* @param event The input event object
6357
*/
6458
onInput(event: Event): void {
65-
event.preventDefault();
6659
const target = event.target as HTMLInputElement;
67-
target.value = target.value.replace(/\D/g, '').slice(0, 6);
68-
this.codeControl.setValue(target.value);
69-
if (!this.codeControl.valid) return;
70-
if (this.codeControl.value === this.#previousSubmission) return;
71-
this.onSubmit();
60+
const sanitized = target.value.replace(/\D/g, '').slice(0, 6);
61+
target.value = sanitized;
62+
this.totpForm.code().value.set(sanitized);
63+
// Reset error state when user starts typing
64+
this.verificationError.set(false);
65+
// Auto-submit only once
66+
if (!this.#previousSubmission()) {
67+
this.onSubmit();
68+
}
7269
}
7370

7471
/**
7572
* Submit authentication code
73+
* @param event the form submit event object
7674
*/
77-
async onSubmit(): Promise<void> {
75+
async onSubmit(event?: Event): Promise<void> {
76+
event?.preventDefault();
7877
if (this.disabled()) return;
79-
const code = this.codeControl.value;
80-
if (!code || !this.codeControl.valid) return;
8178
this.#isProcessing.set(true);
82-
this.#previousSubmission = code;
79+
const code = this.totpForm.code().value();
80+
this.#previousSubmission.set(code);
8381
const verified = await this.#authService.verifyTotpCode(code);
8482
if (verified) {
8583
this.#notify.showSuccess('Successfully authenticated.');

0 commit comments

Comments
 (0)