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, registering, or being created for somebody by an administrator. Not from when the requirement was switched on.
That last case was missing until 0.48.2, and its absence is instructive: an account made for a colleague began with no grace at all, so they met a demand for an authenticator app on the login screen before seeing a single page. The grace exists precisely for those first five minutes, and how the account came to exist has nothing to do with it.
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 square needs ext-iconv; enrolment does not
bacon/bacon-qr-code declares ext-iconv and calls @iconv(). A missing function
is a fatal Error in PHP 8, which @ does not suppress, so on an installation
without the extension asking for the QR code took the whole page down with a 500 —
on both enrolment screens, including the one somebody is held on when they cannot
sign in without it.
That shape is worth recognising because it hides well. Composer resolved the dependency inside a build container that has iconv; the runtime serving the page did not have it. Every test passed and every real request failed.
Where the extension is absent TwoFactor::qrCode() now returns null and the screen
offers the shared secret to type instead. That is the whole of what an authenticator
needs — 1Password, Bitwarden, Aegis and Google Authenticator all accept a typed key —
so the square is a convenience, and its absence degrades enrolment to slower rather
than to impossible. Install ext-iconv to get it back.
One reason it went unreported for as long as it did: an address on the allowlist is never asked for a second factor, so nobody working from the office met the screen. A trusted address hides failures rather than fixing them, which makes it the wrong place to test from.
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.
The list is short on purpose, and it used to be longer. Settings was exempt so that enrolment could be reached from there, and settings carries the API tokens panel — and a token bypasses the second factor completely. So anybody holding a stolen password could mint one from the very screen that was supposed to be stopping them. Enrolment has had a page of its own for some time; settings was exempt out of habit rather than need, and was removed in 0.48.3.
The rule the list has to obey: an exempt route may not do anything except resolve the requirement. A route that is merely convenient to reach while held is a route that is reachable without a second factor.
One more entry is not a page at all. Livewire posts to an address of its own, and a guard that decides by URL does not recognise it — so the two screens built to release somebody were the only screens that could not run: the form saved nothing, the button did nothing, and there was no error either. Fixed in 0.48.3, and worth knowing about any guard written against a path rather than against a request.
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.