Backend:
- New dependency dev.samstevens.totp:totp:1.7.1 (bundles ZXing for QR PNG).
- TotpService: secret/QR/otpauth-URL/verify primitives. SHA1, 6 digits, 30s —
the standard combo every consumer authenticator app accepts unconfigured.
Issuer label is configurable via FINERACT_MFA_TOTP_ISSUER, defaults to
"CBSnexora".
- Two-step device mapping driven by TotpEnrollmentService:
* POST /v1/mfa/totp/setup — generate secret, upsert m_user_mfa_factor row
as factor_type=TOTP/verified=false, return {secret, qrDataUri, otpAuthUrl,
issuer, factorId}. Refuses to overwrite an already-verified-and-active
factor so an accidental click can't unbind the user's working device.
* POST /v1/mfa/totp/verify — body {code}; verify against the stored secret
and flip verified=true on success.
- Login wiring in MfaFactorBackedTwoFactorServiceImpl:
* getDeliveryMethodsForUser surfaces verified TOTP factors as
{name:"totp", target:"Google Authenticator"} so the picker offers it
alongside SMS/EMAIL.
* createNewOTPToken("totp") creates a no-op OTPRequest (no LincPay/SMTP
round-trip) carrying the factor id in the token field so validate can
look up the secret.
* createAccessTokenFromOTP branches on channel — TOTP path verifies code
vs factor.secret with totp library (sliding 1-step window) and mints
a TFAccessToken with the same lifetime + extended-token logic as OTP.
* Defence-in-depth: re-checks at validate time that the factor still
belongs to the user, is verified, and not disabled.
- TwoFactorConstants.TOTP_DELIVERY_METHOD_NAME = "totp".
- SecurityConfig: /api/*/mfa/totp/** is fullyAuthenticated() (exempt from
the TFA gate so users can enrol before they can satisfy 2FA).
UI changes pending (see chat): add a "Google Authenticator" tile on the
MFA Factors settings page that calls /setup → shows QR + secret → calls
/verify; extend two-factor-authentication.component.ts iconFor() and copy
for the new "totp" delivery method. Existing requestOTP / validateOTP
pipeline handles the login flow transparently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>