Two-factor authentication

Where Fineract is configured to require a second authentication factor, this application asks for it: after the password is accepted, the sign-in page asks where to send a one-time code, then for the code itself, and only then lets the user in.

Nothing changes for a deployment that does not use it. The platform simply never asks, and the sign-in page never offers the extra step.

How it fits together

POST /v1/authentication answers with isTwoFactorAuthenticationRequired when the platform wants a second factor. That field — not authenticated, which is true either way — is what decides whether sign-in is finished.

password accepted            ── isTwoFactorAuthenticationRequired ──▶  second step
   │                                                                      │
   │ no second factor                                    GET  /v1/twofactor           (channels)
   │                                                     POST /v1/twofactor           (send code)
   │                                                     POST /v1/twofactor/validate  (exchange)
   ▼                                                                      │
application  ◀────────────────────────────────────────────────────────────┘

Until the exchange succeeds, the Basic credential opens /v1/twofactor/** and nothing else — every other endpoint answers 403. The application reflects that: AuthService.isAuthenticated stays false and twoFactorPending is true, so authGuard keeps the user on the sign-in page and typing a URL does not get them past it.

POST /v1/twofactor/validate returns the session's second-factor token. From then on authInterceptor sends it as Fineract-Platform-TFA-Token on every request, alongside the Authorization header — the platform wants both.

Signing out calls POST /v1/twofactor/invalidate before clearing local state, so the token stops working at the platform rather than merely being forgotten here.

Where the code is

ConcernFile
Session state, twoFactorPending, completion, invalidation on sign-outcore/services/auth.service.ts
The three platform callscore/services/two-factor.service.ts
The Fineract-Platform-TFA-Token headercore/interceptors/auth.interceptor.ts
The second step of the sign-in pagefeatures/login/two-factor/two-factor-step.component.ts

The step collapses the channel choice when the platform offers only one, which is the usual case. A refused code keeps the user on the step, clears the field, and shows the reason Fineract gave — that reason arrives as a 403 carrying a domain-rule violation, which the error interceptor renders in preference to a generic permissions message.

Enabling it locally

Fineract serves /v1/twofactor/** only with fineract.security.2fa.enabled, and the switch is process-wide.

bash scripts/e2e-stack-2fa.sh

That brings up Fineract with the flag set, starts a mail catcher, and points Fineract's SMTP settings at it. The one-time code is delivered by email, so a reachable mail server is part of the setup rather than an optional extra.

Sign in as mifos / password, then read the code from the catcher at http://localhost:8025.

Because the switch is process-wide, this stack and the ordinary one are alternatives — the rest of the suite cannot run while two-factor authentication is on.

Tests

# The matrix, against mocked endpoints. No stack, runs on every pull request.
npx playwright test --project=mocked two-factor-authentication.spec.ts

# The real thing: a real Fineract demanding a factor, a real emailed code.
bash scripts/e2e-stack-2fa.sh
npm run test:e2e:2fa

The mocked suite covers the channel choice, a wrong code, recovery after a wrong code, a resend, an account with no channel configured, a send that fails, a half-finished session being refused by URL, backing out, and — the one that protects every existing installation — a deployment where the platform asks for no second factor behaving exactly as it did before.

The real-backend suite proves the part mocks cannot: that a token Fineract actually issued, and actually emailed, gets a real session past a platform that is refusing everything else. It reads the code out of the mail catcher‘s API, which stands in for the user’s inbox — e2e/utils/mailpit.ts. It runs in its own Playwright project (two-factor) and its own CI job, kept out of the default run because of the process-wide switch.

Unit coverage sits with the code: auth.service.spec.ts for the state machine and sign-out, auth.interceptor.spec.ts for the header, and two-factor-step.component.spec.ts for the step.

Adding a delivery channel

The channels come from the platform — GET /v1/twofactor returns what a user can receive a code on, and the step renders whatever it is given. A new channel needs configuring in Fineract, not here.