Two-Factor Authentication
Two-factor authentication is SMS-based and backed by Authy. It is enforced on the account-management area and can be triggered on demand elsewhere.
Moving parts
| Piece | Location |
|---|---|
Feature slice |
|
Orchestrating service |
|
Modal service |
|
Setup service |
|
Route guards |
|
Setup banner |
|
Widgets |
|
Banner component |
|
Permission |
|
State shape
export const twoFAFeatureKey = '2fa';
export interface TwoFAState {
authyId?: string; // Authy registration identifier
authyRegVerified?: boolean; // phone number has been verified
countryCode?: string;
phoneNumber?: string;
twoFactChangeConfirmed?: boolean;
initialized: boolean; // the slice has been loaded at least once
loading?: boolean;
error?: boolean;
register2FAPhoneSend?: boolean;
smsSend?: boolean;
}
A supplier counts as configured when authyId is present and
authyRegVerified is true.
Enrolment steps
export enum TwoFactorAuthenticationSteps {
RegisterPhoneNumber = 'register',
SendSms = 'sendSms',
VerifyCode = 'test',
}
Actions
| Action | Meaning |
|---|---|
|
Load the current 2FA configuration. |
|
Configuration received; sets |
|
Register a phone number with Authy. Accepts |
|
Registration accepted; resets |
|
Registration rejected. |
|
Request an SMS code. |
|
SMS dispatch outcome. |
|
Verify the code during enrolment. |
|
Enrolment verification outcome. |
|
Verify the code during a challenge (accepts extra headers). |
|
Challenge passed. |
|
|
Enforcement logic
TwoFactorAuthenticationService.checkTwoFactorAuthenticationState(force) is the
single decision point, called by TwoFactorAuthenticationGuard.
async checkTwoFactorAuthenticationState(force = false): Promise<boolean> {
const is2FaConfigured = await this.is2FaConfigured().toPromise();
const is2FaSmsVerified = !force ? this.userService.isTFAConfirmed() : false;
switch (true) {
case !is2FaConfigured:
!this.checkInProgress && this.configure2FA().toPromise().then(); (1)
return false;
case is2FaSmsVerified:
return true; (2)
default:
return await this.open2FaSMSCodeVerificationModal(force).toPromise(); (3)
}
}
| 1 | Not enrolled → open the setup wizard and deny navigation. A checkInProgress
flag prevents a second wizard from stacking. |
| 2 | Already challenged this session and force is not set → allow. |
| 3 | Enrolled but not challenged (or force is set) → open the SMS code modal and
let the result decide. |
force comes from route.data.force2FAConfirmation. ManageAccountModule
sets it to true, so entering account settings always re-challenges even if the
session was already verified.
Enrolment flow
/manage-account/security/two-factor
→ TwoFactorInfoLoadedGuard dispatches fetch2FAInfo() and waits for loaded2FAInfo
→ two-factor-setup-widget renders the current state
Step 1 register → register2FAPhoneNumber({ countryCode, phoneNumber })
→ register2FAPhoneNumberSuccess (authyRegVerified = false)
Step 2 sendSms → sendSms() → sentSmsSuccess
Step 3 test → verifySmsCode({ verificationCode })
→ phoneVerified (authyId + authyRegVerified now set)
Phone input uses ngx-intl-tel-input with google-libphonenumber validation, so
the country code and national number are validated client-side before the API is
called.
Email confirmation link
Confirmation emails link to /login/confirm/email/:emailcode, which the root
router redirects to manage-account/two-factor/confirm/:emailcode. That route
renders ManageAccountComponent, which consumes the code and dispatches
confirmSMSCode. Success dispatches userConfirmed2Fa; failure dispatches
userConfirmed2FaError.
Setup banner
TwoFactorSetupBannerInitializerService runs from
GlobalSubscriptionsService.init(). If the supplier has not enrolled it shows a
persistent banner (definitions in constants/two-factor-banners.const.ts) whose
CTA opens the setup wizard. The banner is also available as a standalone
component, two-factor-authentication-required-banner.
Error handling
Both register2FAPhoneNumber and sendSms accept a
bypassHttpErrorsInterceptor flag. When set, the effect adds the
bypassHttpErrorHandlerInterceptor header so the generic error toast is
suppressed and the modal can render an inline, field-level message instead. See
HTTP Interceptors.