Two-factor authentication
Built on Laravel Fortify — its TOTP implementation, recovery codes and QR payload — with Félagi's own pages around it.
Fortify's HTTP routes are switched off. It registers a /login of its own, and
using its endpoints as well as our Livewire pages would leave two ways in, one of
them untested.
When it is required
# FELAGI_2FA_REQUIRED=true
FELAGI_2FA_GRACE_DAYS=5
Unset means: required in production, nowhere else. A second factor on a developer's machine protects nothing and gets in the way of every test run, which is how people end up disabling it in a way that follows them into production.
Setting the variable overrides that in either direction — on in staging, off in
production if somebody truly must. The security page in /system says which of
the two is in force and why.
The grace period
Five days, counted from when an account begins — accepting an invitation, or registering. Not from when the requirement was switched on.
That distinction matters twice:
- A new colleague can read an issue before being marched through an authenticator app. Being made to do it in the first thirty seconds is how the secret ends up in a note on somebody's desk.
- Somebody who has been here a year does not get five fresh days because an administrator changed a setting this morning.
The grace is spent the moment a second factor is confirmed, and /system/security
lists who has not enrolled and how long each of them has left.
Single sign-on satisfies it
Somebody who signed in through an identity provider that does MFA has already proved a second factor, and will never enrol here — a provider account has no password for a TOTP secret to protect.
That is checked before enrolment rather than through the session flag alone: an unenrolled account is sent to the enrolment page whatever the session says, so marking the session verified was not enough by itself.
Per provider, in case one is configured without MFA. See Single sign-on.
The allowlist
Addresses that do not have to prove a second factor — an office range, typically. Single addresses and CIDR ranges, both families.
It is a platform setting, in /system, not a workspace one. This is
deliberate and it is the one place this feature departs from where it was asked
for. Anybody can create a workspace, so a workspace-scoped allowlist would let
any user switch off a security control by making a workspace of their own and
allowing 0.0.0.0/0 in it. A control that turns off authentication belongs with
whoever is accountable for the platform.
If the allowlist should live in /admin, the prerequisite is restricting who can
create a workspace.
Two smaller decisions:
- Behind a proxy, set
TRUSTED_PROXIES. Without it the address Félagi sees is the proxy's, and allowlisting that exempts the entire internet. The security page shows the address it currently sees, so this is visible before anybody adds it. - The address is trusted, not the account. The same person from a café still proves who they are; the exemption is per request.
- Last-matched is recorded, at most once a minute, so a range nobody uses any more can be spotted and removed rather than left open forever.
Enrolling
There are two ways in, and they run the same code.
Settings carries a two-factor panel, between the password form and the danger zone — both of those are about how you sign in, and turning on a protection is not a destructive act. This is the one that matters during the grace period: without it the only route to the setup screen was being stopped by the middleware, which meant nobody could act on the warning they had been given.
The panel is also there where two-factor is not required at all, so a team that wants it need not wait for production to ask.
/two-factor/setup is the same steps with the framing of a door rather than a
preference — the screen somebody is held on once their grace runs out.
Both call App\Support\TwoFactor, so the rule about spending the grace cannot
end up in only one of them.
Either way it asks for the password in the same step. Somebody with a hijacked session must not be able to attach their own authenticator.
Enrolment is not complete until a code has been entered. Fortify calls this confirmation, and without it somebody can scan a QR code they never test and lock themselves out of their own account. Recovery codes are shown before it is switched on, because afterwards is too late to be told to write them down.
The challenge
/two-factor/challenge, once per session. Codes are verified through Fortify's
provider: TOTP has a window, a drift tolerance and a replay problem, and none of
that is worth re-deriving.
A recovery code is spent on use. Otherwise it is a second password with none of the protections, written on a piece of paper.
Where the gate sits
RequireTwoFactor runs on the whole authenticated application rather than only
at login, because a session established before the requirement was switched on
has to meet it too — and can only be asked on its next request.
Three routes stay reachable: the two pages that resolve the requirement, and logout. Otherwise somebody would be held on a screen they cannot use.
A JSON request gets a 403 rather than a redirect to HTML it cannot read.
Not there yet
- No enforcement per workspace. The requirement is platform-wide; a workspace cannot demand more than the platform does.
- No trusted devices. The challenge is once per session, not once per device per thirty days.
- No WebAuthn as a second factor. Passkeys exist in the starter kit as a way to sign in, not as a factor on top of a password.
- No administrative reset. Somebody who loses both their device and their recovery codes needs a platform admin with database access.