Customer portal MFA and recovery codes

Engineering reference for customer-portal multi-factor authentication in ethica-api: TOTP plus recovery codes (same conceptual model as ERP staff MFA, separate implementation and keys).

Model

  • TOTP from an authenticator app (CustomerMfaCredential, encrypted secret with CUSTOMER_MFA_ENCRYPTION_KEY).
  • Recovery codes: independent one-time codes, bcrypt-hashed at rest in customer_mfa_credentials.backupCodesHashes (JSON array of hashes). Each successful use removes one hash. MFA stays enabled after using a code at login (this is not a full MFA reset).

Count: 10 codes per completed enrollment (staff ERP uses 8 in staff/mfa.service.ts; customers use the constant in customer-mfa.service.ts).

Codes are not interchangeable with staff codes (different issuer, encryption key, and database rows).

Enrollment and display

  • After POST /customer-auth/mfa/complete, the API returns plaintext codes once; the client must show them with copy, download as .txt, and explicit user acknowledgment before continuing (same UX pattern as ERP MfaEnrollmentPanel).

Login step-up

  • POST /customer-auth/login/mfa accepts either a 6-digit TOTP or one recovery code.
  • Use a customer-specific request DTO so code allows backup code length (do not cap at staff LoginMfaDto max length if it blocks hex codes).
  • The OTP UI must accept non-digit input for the recovery path (not digits-only).

Exhaustion (no codes left)

When the last hash is consumed:

  1. Email: Transactional message to the customer’s contact email (same resolution as other portal emails, e.g. portal credentials), warning that no recovery codes remain and pointing to Settings to regenerate. Requires RESEND_API_KEY (and mail from-address) on the API.
  2. GET /customer-auth/me: Expose a non-secret field such as recoveryCodesRemaining (or an exhausted flag) for dashboard/settings banners.

Settings and regeneration (planned / product-dependent)

  • Settings should include change password and regenerate recovery codes.
  • Regeneration: authenticated endpoint (e.g. POST /customer-auth/mfa/recovery-codes/regenerate) requiring current password + TOTP, replaces all hashes with a new set, returns plaintext once, rate-limited, and ideally audit-logged.

Environment variables (names only)

VariablePurpose
CUSTOMER_MFA_ENCRYPTION_KEYRequired in production. 32-byte value, base64-encoded; encrypts customer TOTP secrets. Do not reuse staff MFA_ENCRYPTION_KEY.
CUSTOMER_MFA_ISSUEROptional; issuer shown in authenticator apps.

Other JWT/session variables align with shared security.config.ts and customer session handling.

Database

  • customer_mfa_credentials: customerPortalAccountId, organizationId, encryptedSecret, enabled, backupCodesHashes.

API routes (customer-auth)

MethodPathAuthDescription
POST/customer-auth/loginNoPassword login; may return mfaRequired + preAuthToken.
POST/customer-auth/login/mfaNopreAuthToken + TOTP or one recovery code → full JWT.
GET/customer-auth/meJWTIdentity, mfaEnrollmentRequired, recoveryCodesRemaining (or equivalent).
GET/customer-auth/mfa/setupJWTStart TOTP enrollment (pending until verified).
POST/customer-auth/mfa/completeJWTVerify TOTP; enable MFA; return plaintext recovery codes once.
PATCH/customer-auth/passwordJWTChange password.
POST/customer-auth/mfa/recovery-codes/regenerateJWTWhen implemented: password + TOTP → new codes; invalidates old.
  • ethica-api/src/customer-auth/customer-mfa.service.ts
  • ethica-api/src/customer-auth/customer-auth.service.ts
  • ethica-api/src/customer-auth/customer-auth.controller.ts
  • ethica-api/src/models/customer-mfa-credential.model.ts
  • ethica-api/src/mail/templates/ (exhaustion email template, when present)

See also