- Authenticator app registered
- Sign-in will require a 6-digit TOTP code from your app.
+ Google Authenticator linked
+ Sign-in will require a 6-digit code from your authenticator app.
}
diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss
index 2b9e1e9e6..c32fb5944 100644
--- a/src/app/access-control/access-control.component.scss
+++ b/src/app/access-control/access-control.component.scss
@@ -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;
diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts
index 8c36fcb7a..c1b0138c6 100644
--- a/src/app/access-control/access-control.component.ts
+++ b/src/app/access-control/access-control.component.ts
@@ -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 . 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,
diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts
index 10c73be32..197c3eb09 100644
--- a/src/app/access-control/access-control.service.ts
+++ b/src/app/access-control/access-control.service.ts
@@ -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 . */
+ 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 {
+ return this.http.post('/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 {
+ return this.http.post('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders });
+ }
}
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.html b/src/app/login/two-factor-authentication/two-factor-authentication.component.html
index 95663db2d..fcfb99a47 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.html
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.html
@@ -7,6 +7,15 @@
-->
+
+
+
+
+ CBSNexora
+ Secure Sign-in
+
+
+
@@ -17,6 +26,8 @@
Plug in your DSC token and follow the prompts in the helper.
} @else if (!otpRequested) {
Choose how you want to verify your identity.
+ } @else if (isSelectedTotp) {
+ Open your authenticator app and enter the 6-digit code below.
} @else {
Enter the 6-digit code we sent you to continue.
}
@@ -140,13 +151,25 @@
- Code sent to
- {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}
+ @if (isSelectedTotp) {
+ Open your authenticator app
+ Enter the 6-digit code
+ } @else {
+ Code sent to
+ {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}
+ }