feat(mfa): Google Authenticator (TOTP) + branded 2FA login popup

- Settings → MFA Factors: wire the Authenticator tile to the real backend.
  POST /v1/mfa/totp/setup mints the QR + secret + otpauth URL; the response
  binds directly to <img [src]="totp.qrDataUri"> alongside a manual-entry
  fallback ("Can't scan? Enter this in your app:") and an issuer/account
  label. Verify hits POST /v1/mfa/totp/verify; on
  error.msg.user.mfa.totp.code.invalid the message renders inline. Disable
  uses DELETE /mfa/factors/{id}.
- Already-enrolled guard surfaces a dialog and re-hydrates from
  /mfa/factors when error.msg.user.mfa.totp.already.enrolled is returned.
- Mobile click-to-add deep-link bound to otpAuthUrl (shown on touch-only
  browsers via @media (hover:none)).
- access-control.service.ts: TotpSetupResponse + TotpVerifyResponse +
  setupTotp() / verifyTotp() endpoints.
- hydrateFromServer / resetAllFactors now treat TOTP as a persisted factor
  (verified, factorId, verifiedOn) instead of local state.
- Login 2FA picker: iconFor() returns 'qrcode' for TOTP, OTP-context copy
  switches to "Open your authenticator app", Resend button hidden,
  validity badge reads "rotates every 30 s".
- 2FA popup redesigned: CBS Nexora brand lockup at the top (logomark +
  wordmark + 'Secure Sign-in' sub-line), refined card depth with stacked
  shadow + ambient glow, segmented 6-cell OTP input, brighter primary
  button gradient, responsive collapse at <=480px.
- Register faQrcode in icons.module.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-15 17:45:07 +05:30
co-authored by Claude Opus 4.7
parent 595054b0b8
commit 4322f37c6d
10 changed files with 500 additions and 147 deletions
@@ -622,32 +622,36 @@
</p>
<div class="ac-panel__body">
@if (!totp.secret && !totp.verified) {
@if (!totp.qrDataUri && !totp.verified) {
<div class="ac-totp-empty">
<mat-icon>qr_code_scanner</mat-icon>
<div>
<strong>No authenticator linked yet</strong>
<p>Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.</p>
<p>
Click <strong>Set up</strong> to generate a QR for Google Authenticator (or any TOTP app), then enter
the 6-digit code to activate.
</p>
</div>
<button mat-flat-button color="primary" (click)="generateTotpSecret()">
<mat-icon>auto_awesome</mat-icon> Generate setup key
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="beginTotpSetup()">
<mat-icon>auto_awesome</mat-icon> {{ totp.busy ? 'Starting…' : 'Set up' }}
</button>
</div>
} @else if (totp.secret && !totp.verified) {
} @else if (totp.qrDataUri && !totp.verified) {
<div class="ac-totp-setup">
<div class="ac-totp-setup__qr">
<div class="ac-qr-frame">
<div class="ac-qr-grid">
@for (row of totpQrCells; track $index) {
<div class="ac-qr-row">
@for (cell of row; track $index) {
<span class="ac-qr-cell" [class.is-on]="cell"></span>
}
</div>
}
</div>
<img class="ac-qr-img" [src]="totp.qrDataUri" alt="Authenticator setup QR" />
</div>
<p class="ac-qr-caption">Scan in your authenticator app</p>
<p class="ac-qr-issuer">
Issuer: <strong>{{ totp.issuer }}</strong> · {{ totp.accountName }}
</p>
@if (totp.otpAuthUrl) {
<a class="ac-qr-deeplink" [href]="totp.otpAuthUrl">
<mat-icon>smartphone</mat-icon>
On a phone? Tap to add to your authenticator
</a>
}
</div>
<div class="ac-totp-setup__details">
<div class="ac-totp-step-list">
@@ -658,7 +662,7 @@
<div class="ac-totp-step">
<span class="ac-totp-step__num">2</span>
<div>
Scan the code on the left, or enter the secret manually.
Scan the code on the left. <em>Can't scan?</em> Enter this in your app:
<div class="ac-totp-secret">
<code>{{ totp.secret }}</code>
<button mat-icon-button matTooltip="Copy" (click)="copyTotpSecret()">
@@ -669,7 +673,7 @@
</div>
<div class="ac-totp-step">
<span class="ac-totp-step__num">3</span>
<div>Enter the 6-digit code shown by your app to verify and register this device.</div>
<div>Enter the 6-digit code shown by your app to verify and activate.</div>
</div>
</div>
@@ -690,9 +694,9 @@
</mat-form-field>
<div class="ac-totp-actions">
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="verifyTotp()">
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & Register' }}
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & activate' }}
</button>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<button mat-stroked-button color="warn" [disabled]="totp.busy" (click)="cancelTotpSetup()">
<mat-icon>refresh</mat-icon> Discard & restart
</button>
</div>
@@ -702,11 +706,11 @@
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Authenticator app registered</strong>
<span>Sign-in will require a 6-digit TOTP code from your app.</span>
<strong>Google Authenticator linked</strong>
<span>Sign-in will require a 6-digit code from your authenticator app.</span>
</div>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<mat-icon>power_settings_new</mat-icon> Unlink
<button mat-stroked-button color="warn" [disabled]="totp.busy" (click)="disableTotp()">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
</div>
}
@@ -1131,6 +1131,58 @@
color: #64748b;
}
.ac-qr-img {
width: 168px;
height: 168px;
display: block;
image-rendering: pixelated;
border-radius: 4px;
}
.ac-qr-issuer {
margin: 4px 0 0;
font-size: 11.5px;
color: #475569;
strong {
color: #0f172a;
}
}
.ac-qr-deeplink {
display: none;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 7px 12px;
border-radius: 999px;
background: #eff6ff;
border: 1px solid #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
text-decoration: none;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:hover {
background: #1d4ed8;
color: #fff;
border-color: #1d4ed8;
}
}
/* Show the click-to-add link on touch / handheld widths */
@media (width <= 760px), (hover: none) {
.ac-qr-deeplink {
display: inline-flex;
}
}
.ac-totp-step-list {
display: flex;
flex-direction: column;
@@ -63,9 +63,24 @@ interface OtpChannelState {
interface TotpState {
typeCode: 'TOTP';
/** Base-32 shared secret returned by /mfa/totp/setup (manual-entry fallback). */
secret: string;
/** `data:image/png;base64,...` — bound directly to <img [src]>. Empty when no live setup. */
qrDataUri: string;
/** Full otpauth:// URL — used by the mobile click-to-add deep-link. */
otpAuthUrl: string;
/** Issuer label shown by the authenticator app. */
issuer: string;
/** Account label shown by the authenticator app (typically the username). */
accountName: string;
/** 6-digit code the user enters from their authenticator. */
otp: string;
/** True once the factor is persisted on the server. */
verified: boolean;
/** Persisted factor id for disable. */
factorId: number | null;
/** ISO timestamp from the server. */
verifiedOn: string;
errorMsg: string;
busy: boolean;
}
@@ -142,14 +157,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
email: OtpChannelState = this.makeOtpState('EMAIL');
/** Section 2 — Authenticator app (TOTP) */
totp: TotpState = {
typeCode: 'TOTP',
secret: '',
otp: '',
verified: false,
errorMsg: '',
busy: false
};
totp: TotpState = this.makeTotpState();
/** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */
hardware: HardwareItem[] = [
@@ -310,9 +318,19 @@ export class AccessControlComponent implements OnInit, OnDestroy {
this.applyFactor(this.sms, f);
} else if (f.factorType === 'EMAIL') {
this.applyFactor(this.email, f);
} else if (f.factorType === 'TOTP' && f.verified) {
this.totp.secret = f.secret || this.totp.secret;
this.totp.verified = true;
} else if (f.factorType === 'TOTP') {
// Verified+active TOTP means the user already has Google Authenticator
// enrolled. Don't expose the secret again — only after a fresh setup.
const isActive = !f.disabledOn && !!f.verified;
this.totp.verified = !!f.verified;
this.totp.factorId = isActive ? (f.id ?? null) : null;
this.totp.verifiedOn = f.verifiedOn || '';
// Clear any pending setup state — server is authoritative.
this.totp.secret = '';
this.totp.qrDataUri = '';
this.totp.otpAuthUrl = '';
this.totp.otp = '';
this.totp.errorMsg = '';
} else if (f.factorType === 'DSC') {
// Match by serial, falling back to the DSC tile if no serial match
// (DSC is the only hardware kind backed by the factor API today).
@@ -359,6 +377,12 @@ export class AccessControlComponent implements OnInit, OnDestroy {
this.email.verifiedOn = '';
this.totp.verified = false;
this.totp.secret = '';
this.totp.qrDataUri = '';
this.totp.otpAuthUrl = '';
this.totp.otp = '';
this.totp.errorMsg = '';
this.totp.factorId = null;
this.totp.verifiedOn = '';
for (const h of this.hardware) {
h.registered = false;
h.serial = '';
@@ -608,47 +632,45 @@ export class AccessControlComponent implements OnInit, OnDestroy {
// ───────────────────────────── Authenticator app (TOTP)
generateTotpSecret(): void {
this.totp.secret = this.randomBase32(16);
this.totp.otp = '';
this.totp.verified = false;
/**
* Begin TOTP enrolment: ask the backend to mint a secret + QR. Populates
* `qrDataUri`, `secret`, `otpAuthUrl`, `issuer` from the response so the
* UI can render the QR and the manual-entry fallback in one step.
*
* If the user already has TOTP enrolled, the backend returns
* `error.msg.user.mfa.totp.already.enrolled` — we surface it as a toast.
*/
beginTotpSetup(): void {
this.totp.errorMsg = '';
}
/** otpauth URI shown for manual entry / QR encoding by the user's authenticator app */
get totpOtpauthUri(): string {
if (!this.totp.secret) return '';
const issuer = 'CBSNexora';
const label = `${issuer}:${this.username}`;
return `otpauth://totp/${encodeURIComponent(label)}?secret=${this.totp.secret}&issuer=${encodeURIComponent(issuer)}`;
}
/** Visual placeholder QR — deterministic 8x8 grid from the secret string */
get totpQrCells(): boolean[][] {
if (!this.totp.secret) return [];
const grid: boolean[][] = [];
let seed = 0;
for (let i = 0; i < this.totp.secret.length; i++) {
seed = (seed * 31 + this.totp.secret.charCodeAt(i)) & 0xffffffff;
}
for (let r = 0; r < 8; r++) {
const row: boolean[] = [];
for (let c = 0; c < 8; c++) {
// Three corner finder squares
const corner =
(r < 3 && c < 3) || (r < 3 && c > 4) || (r > 4 && c < 3)
? r === 0 || r === 2 || r === 5 || r === 7 || c === 0 || c === 2 || c === 5 || c === 7
: null;
if (corner !== null) {
row.push(corner);
this.totp.busy = true;
this.accessControlService.setupTotp().subscribe({
next: (res) => {
this.totp.busy = false;
this.totp.qrDataUri = res?.qrDataUri || '';
this.totp.secret = res?.secret || '';
this.totp.otpAuthUrl = res?.otpAuthUrl || '';
this.totp.issuer = res?.issuer || 'CBSNexora';
this.totp.accountName = res?.accountName || this.username;
this.totp.otp = '';
this.totp.verified = false;
this.totp.factorId = null;
this.totp.verifiedOn = '';
},
error: (err) => {
this.totp.busy = false;
const apiMsg =
err?.error?.userMessageGlobalisationCode || err?.error?.defaultUserMessage || err?.error?.message;
if (apiMsg === 'error.msg.user.mfa.totp.already.enrolled' || /already.enrolled/i.test(apiMsg || '')) {
this.showError(
'Google Authenticator is already enrolled. Disable the existing factor before setting it up again.'
);
// Re-hydrate so the UI matches the server's reality.
this.hydrateFromServer();
} else {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
row.push((seed & 1) === 1);
this.showError(apiMsg || 'Could not start TOTP setup. Try again.');
}
}
grid.push(row);
}
return grid;
});
}
copyTotpSecret(): void {
@@ -660,6 +682,11 @@ export class AccessControlComponent implements OnInit, OnDestroy {
}
}
/**
* Submit the 6-digit code from the authenticator app to /mfa/totp/verify.
* On success the backend persists the factor row; on
* `error.msg.user.mfa.totp.code.invalid` we show the message inline.
*/
verifyTotp(): void {
const code = (this.totp.otp || '').trim();
if (!/^\d{6}$/.test(code)) {
@@ -668,25 +695,72 @@ export class AccessControlComponent implements OnInit, OnDestroy {
}
this.totp.errorMsg = '';
this.totp.busy = true;
setTimeout(() => {
this.totp.busy = false;
this.totp.verified = true;
this.savePrefs();
}, 500);
this.accessControlService.verifyTotp(code).subscribe({
next: (res) => {
this.totp.busy = false;
if (!res?.verified) {
this.totp.errorMsg = res?.message || 'Invalid code — try a fresh one from your authenticator.';
return;
}
this.totp.verified = true;
this.totp.factorId = res.factorId ?? null;
this.totp.verifiedOn = res.verifiedOn || new Date().toISOString();
// Clear setup-only artefacts now that the factor is live.
this.totp.qrDataUri = '';
this.totp.secret = '';
this.totp.otpAuthUrl = '';
this.totp.otp = '';
this.savePrefs();
},
error: (err) => {
this.totp.busy = false;
const code = err?.error?.userMessageGlobalisationCode || err?.error?.errors?.[0]?.userMessageGlobalisationCode;
if (code === 'error.msg.user.mfa.totp.code.invalid' || /code\.invalid/i.test(code || '')) {
this.totp.errorMsg = 'That code is incorrect or expired. Try the next one (codes rotate every 30 s).';
} else {
this.totp.errorMsg = err?.error?.defaultUserMessage || err?.error?.message || 'Verification failed.';
}
}
});
}
/** Discard a pending setup (before verify) — clears the QR/secret but does NOT call the API. */
cancelTotpSetup(): void {
this.totp.qrDataUri = '';
this.totp.secret = '';
this.totp.otpAuthUrl = '';
this.totp.otp = '';
this.totp.errorMsg = '';
}
/** Disable an enrolled TOTP factor — DELETE /mfa/factors/{id}. */
disableTotp(): void {
this.totp = { typeCode: 'TOTP', secret: '', otp: '', verified: false, errorMsg: '', busy: false };
this.savePrefs();
if (!this.totp.verified) {
this.cancelTotpSetup();
return;
}
const finish = () => {
this.totp = this.makeTotpState();
this.savePrefs();
this.showSuccess(
'Authenticator app unlinked',
'The next sign-in will no longer require a 6-digit code from your authenticator.'
);
};
if (this.mfaFactorsApiAvailable && this.totp.factorId != null) {
this.totp.busy = true;
this.accessControlService.disableFactor(this.totp.factorId).subscribe({
next: () => finish(),
error: () => finish() // local state already reflects intent; keep UX consistent
});
} else {
finish();
}
}
private randomBase32(len: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = '';
for (let i = 0; i < len; i++) {
out += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
return out;
/** Mobile deep-link: tapping this on a phone hands the secret off to the authenticator. */
get totpDeepLink(): string {
return this.totp.otpAuthUrl || '';
}
// ───────────────────────────── Hardware Token
@@ -1010,6 +1084,23 @@ export class AccessControlComponent implements OnInit, OnDestroy {
// ───────────────────────────── Persistence (localStorage stub)
private makeTotpState(): TotpState {
return {
typeCode: 'TOTP',
secret: '',
qrDataUri: '',
otpAuthUrl: '',
issuer: 'CBSNexora',
accountName: this.username || '',
otp: '',
verified: false,
factorId: null,
verifiedOn: '',
errorMsg: '',
busy: false
};
}
private makeOtpState(typeCode: 'SMS' | 'EMAIL'): OtpChannelState {
return {
typeCode,
@@ -24,6 +24,28 @@ export interface OtpVerifyResponse {
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
/** Payload returned from POST /v1/mfa/totp/setup — ready to bind directly to the UI. */
export interface TotpSetupResponse {
/** `data:image/png;base64,...` — drops straight into <img [src]>. */
qrDataUri: string;
/** Base-32 shared secret (the manual-entry fallback when QR can't be scanned). */
secret: string;
/** Full `otpauth://` URL — useful for the mobile "click to add" deep-link. */
otpAuthUrl: string;
/** Issuer label the authenticator app shows (e.g. "CBSNexora"). */
issuer: string;
/** Optional account label the authenticator app shows (e.g. the username). */
accountName?: string;
}
/** Response from POST /v1/mfa/totp/verify. Either echoes the saved factor or a verified flag. */
export interface TotpVerifyResponse {
verified: boolean;
factorId?: number;
verifiedOn?: string;
message?: string;
}
export interface MfaFactor {
id?: number;
factorType: MfaFactorType;
@@ -96,4 +118,21 @@ export class AccessControlService {
headers: this.silentHeaders
});
}
// ───── TOTP (Google Authenticator and friends) ─────
/**
* Begin TOTP enrolment: backend mints a fresh secret, returns the QR (as a
* ready-to-use data URI) plus the secret and otpauth URL for fallback flows.
*/
setupTotp(): Observable<TotpSetupResponse> {
return this.http.post<TotpSetupResponse>('/v1/mfa/totp/setup', {}, { headers: this.silentHeaders });
}
/**
* Confirm enrolment by submitting the 6-digit code from the authenticator
* app. Backend persists the verified factor + returns its id.
*/
verifyTotp(code: string): Observable<TotpVerifyResponse> {
return this.http.post<TotpVerifyResponse>('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders });
}
}