Skip to content

[3.0] External authentication (part 5 of 5) — asks who you are again before the way in changes - #9492

Open
albertlast wants to merge 7 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-stepup
Open

[3.0] External authentication (part 5 of 5) — asks who you are again before the way in changes#9492
albertlast wants to merge 7 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-stepup

Conversation

@albertlast

Copy link
Copy Markdown
Collaborator

Description

Part 5 of 5, on top of #9491. Review #9381, #9488, #9489 and #9491 first.

Two holes, both about proving it is still you. The second one is a live lockout
that parts 1 to 4 created.

A member with no password could not get into the administration centre.
User::validateSession() is the gate before the admin and moderation areas: it
asks for the password a second time and remembers for an hour that you got it
right. Parts 1 to 4 made accounts that have no password, so that question had no
answer — the member reached a password box they could not fill and was shut out
for good. The comment part 1 left in that method ("nothing in SMF itself creates
a member without a password") stopped being true the moment part 4 landed.

What the gate has always actually read is the stamp in the session, not the
password. So a passkey, or a trip back to the identity provider, now writes the
same stamp, and everything downstream is unchanged.

Adding a way in asked for nothing at all. SMF makes a member retype their
password to turn a second factor off, while parts 2 and 3 let anyone holding
the session attach a passkey or link a provider. That is the more dangerous
direction: the new credential outlives the session it was added from, and
changing the password does not revoke it. ?action=profile;area=passkeys and
;area=linkedaccounts now ask first, and so do the endpoints behind them, for
the request that did not come from those pages at all.

New class SMF\Authentication\StepUp owns the whole question: what a member
may be asked to prove, how long an answer lasts, whether one is still good, and
which methods this particular member could answer with. validateSession() and
ACP::adminLogin() both moved onto it, which also removes the copy of the
purpose-validation they each carried.

Two new sub-actions, ?action=passkey;sa=reauth and
?action=authext;sa=reauth. Neither signs anybody in, links anything or creates
anything — they answer the question and send the member back to the page that
asked it. The OIDC one sends prompt=login and max_age=0, without which the
provider would answer out of the session it already had and prove nothing.

The windows are settings, not opinionsauth_stepup_lifetime (default an
hour, matching today's behaviour) and auth_credentials_lifetime (default five
minutes), beside securityDisable where an admin already decides how much of
this they want. securityDisable_credentials switches the new check off
entirely. Changing the way into an account gets the shorter default because an
hour-old answer is a poor thing to accept for a change that permanent, but that
is the admin's call and not mine.

Three things a proof must not do, and now cannot:

  • a passkey belonging to somebody else proves nothing about the member holding
    the session — the credential has to be theirs;
  • signing in at a provider account that is not the one linked here proves
    nothing either;
  • a challenge issued to sign in cannot answer a step up, or the reverse. The
    purpose is recorded with the challenge and checked when it comes back.

A member with no password, no passkey and no linked provider is told so plainly
instead of being handed a form they cannot fill in.

How this was tested

No test suite, so this was driven over real HTTP against the Docker environment,
with a forged authenticator standing in for navigator.credentials and a real
Keycloak standing in for the provider. 106 checks, all passing — 58 for this
PR, plus the 48 from #9491 re-run against it.

Passkey and password step-up, 40 checks:

  • the passkeys page stops to ask, and the endpoint behind it refuses on its own
  • a password opens it; a wrong password does not
  • a passkey opens it too, and sends the member back where they were
  • the administration centre: it asks, offers both the password and the
    passkey, and the passkey opens it
  • an administrator with no password at all: asked, not shut out — no
    password box, an explanation, the passkey offered, and it lets them in
  • refused: a member with no passkey, another member's passkey, a sign-in
    challenge used as a step up, an unrecognised purpose (narrowed, not widened)
  • the window is honoured: a fresh answer opens the page, a stale one does not
  • with securityDisable_credentials set, nothing asks and nothing refuses
  • an account with no method at all is told so, with no password box

Identity provider step-up, 18 checks:

  • an account created through the provider, with no password
  • the linked accounts page asks, does not ask for a password it hasn't got, and
    offers the provider
  • the round trip carries prompt=login, comes back, and opens the page
  • nobody is signed in again, nothing is linked twice, no account is created
  • an account at the provider that is not the linked one proves nothing, the page
    stays shut, and nothing is written

Regression, 48 checks: #9491's whole suite, re-run. The only change needed was
that its "add a second passkey afterwards" section now has to prove itself
first — which it does with the passkey the member signed up with, since that
account has no password. That is the gate working.

smf_log_errors held only the deliberate refusals afterwards. composer lint,
phplint and the index/licence/EOF/languages checks are clean, and the forum
was put back the way it was found.

Notes for review

  • ErrorHandler::fatalLang() accepts a $file parameter and then ignores it,
    reading Errors.php regardless
    (ErrorHandlerService.php:405).
    Not fixed here; it is why stepup_no_method lives in Errors.php while the
    rest of these strings are in Login.php.
  • Unrelated, found while checking whether SMF re-authenticates before critical
    changes: completing two factor setup fatals on release-3.0.
    TFASetup::execute() builds the QR URL from Utils::$context['tfa_secret'],
    which validateAndSave() only sets on its failure branch, and the secret is
    not stored either. Reproduced on a clean checkout. Happy to open that
    separately.

Issues References (Fixes|Related|Closes)

Related: #9381, #9488, #9489, #9491

albertlast and others added 7 commits August 24, 2026 18:54
Everything about signing in assumes the password form did it. The steps
that follow a successful check live in Login2::DoLogin(), which is
protected and reads its member from a private property, so nothing else
can reuse them; two factor authentication is looked up by reading the
tfa_secret column wherever the question comes up; and every account is
assumed to have a password worth asking for.

None of that is a problem until something else can vouch for a member,
at which point each one has to be worked around rather than used. So:

Moves the body of DoLogin() to Login2::completeLogin(), taking the member
and the cookie lifetime as arguments. DoLogin() now just calls it, so the
password path is unchanged, and anything else that authenticates a member
can finish the job the same way instead of setting the cookie by hand and
missing the ban check or the login history.

Adds User::getSecondFactors(), which reports the factors a member has and
lets a mod add its own, and asks it instead of reading tfa_secret. It
reads the loaded profile rather than object properties because
verifyTfa() runs before setProperties() does. Checking it in
Login2::checkCookie() now also checks tfa_mode, as verifyTfa() already
did; without that a member could be sent to ?action=logintfa when nothing
was going to ask them for a code, which ends in "You are not allowed to
access this section" rather than a login.

Adds User::hasUsablePassword() for accounts that have no password to
give. The login form refuses them before the legacy hash fallbacks get to
compare anything against an empty string, and validateSession() offers
integrate_reauthenticate so such a member is not simply locked out of the
admin areas. Nothing here creates such an account yet.

Adds a member_auth table for whatever credentials those accounts sign in
with, dropped along with the member, and a login form slot that renders
the methods registered through integrate_authentication_methods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Migrations can say whether they still apply, and the upgrader reports the
step as skipped when they do not, which keeps a re-run honest instead of
relying on create() quietly ignoring the table it finds.

Compares against Config::$db_prefix rather than Db::$db->prefix, since the
latter is database qualified while list_tables() reports bare names, and
so would never match. That same mismatch is why Table::exists() is no use
here either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds sign in with an external identity provider, so a forum can hand
authentication to Google, Microsoft, Keycloak, Authentik or anything else
that speaks the protocol, rather than being the only thing that knows a
member's password.

Uses the authorization code flow with PKCE and a confidential client. The
ID token is read from the response to our own back channel POST to the
token endpoint, over TLS with the certificate verified and the client
authenticated, which is the case OpenID Connect Core 3.1.3.7 item 6 allows
signature validation to be skipped in. That is why the certificate check in
OidcClient::fetch() is not optional, and why the token is never taken from
the redirect. Doing it this way keeps JWKS handling, and a hard dependency
on openssl that SMF does not currently have, out of it entirely.

Deliberately does not go through WebFetchApi. That cannot set request
headers, which the token and userinfo endpoints need, and it rewrites the
host to a literal IP, which defeats the certificate check. CurlFetcher
would allow headers but defaults to CURLOPT_SSL_VERIFYPEER false, which is
not something to inherit for a token exchange.

Whose account a sign in belongs to is decided narrowly. The provider's
subject claim is the key and email never is, unless an admin turns that on
per provider and the provider states the address is verified; otherwise
anyone able to get an address issued there could walk into the account
using it here. Somebody with no account is handed to the ordinary sign up
form rather than having one made for them, so the agreement, the privacy
policy, COPPA and admin approval all still apply, and the credential is
attached once that finishes.

Members manage their own links from their profile, where the last one
cannot be removed while it is the only way they can get in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Every directory carries one, and check-smf-index.php enforces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds WebAuthn, so a member can register the fingerprint reader, face scan,
PIN or security key that already unlocks their device and sign in with it
instead of a password. A passkey cannot be replayed and cannot be handed to
a site pretending to be this one, which is the whole reason to want them.

The implementation is bundled under Sources/WebAuthn/ rather than pulled in
as a dependency, following what Sources/TOTP/ already does: a CBOR reader,
a COSE key to PEM converter, the authenticator data structure, and the two
ceremonies. Only ES256 and RS256 are offered, because those are the two we
can check, and only those two are asked for.

Attestation is neither requested nor examined. It says what make of
authenticator produced a credential, which only matters to a site policing
a list of approved models; checking it means shipping trust anchors and
keeping them current, and none of the security of signing in rests on it.

Off by default. Turning it on is one checkbox in the new Authentication
area, which also explains that credentials are tied to the forum's domain
and cannot be moved. openssl is not a hard requirement of SMF, so the
feature hides itself rather than raising what SMF needs everywhere.

Signed-off-by: Mathias Albert <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Everything so far assumed the account already existed. An identity provider
could vouch for somebody with no account here, and SMF sent them to the sign up
form -- where it asked them to invent a password, which is the one thing they
came here not to have. A passkey could not create an account at all.

registerMember() gains a passwordless option, which stores an empty passwd.
Nothing hashes to an empty string, so no password can ever match it, which is
the marker User::hasUsablePassword() already looks for. The option is never
read from the request: only a caller that has arranged some other way in asks
for it.

The sign up form then fills in what the provider said, drops the password
boxes, and says what will be signing them in instead. The passkey route works
the same way, one step earlier: the ceremony runs on the form itself, the
credential waits in the session, and it is attached once there is an account to
attach it to. Nothing is written until the member has been through the
agreement, the age rules and approval, the same as anybody else.

A passkey made before the account existed has no member ID to derive its user
handle from, so it carries a random one, and every passkey added afterwards
reuses it -- otherwise the browser would show one account as several.

Signed-off-by: Mathias Albert <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Two holes, both about proving it is still you.

The administration centre has always asked for the password a second time and
remembered for an hour that you got it right. Parts 1 to 4 made accounts that
have no password, so that question had no answer: the member reached a password
box they could not fill and was shut out of the admin and moderation areas for
good. The stamp in the session, not the password, is what the gate has always
read, so a passkey or a trip back to the identity provider now writes the same
stamp and everything downstream is unchanged.

And adding a way in asked for nothing at all. SMF makes a member retype their
password to turn a second factor off, while parts 2 and 3 let anyone holding
the session attach a passkey or link a provider -- which outlives the session,
and which changing the password does not revoke. Those pages now ask first, on
a shorter window than the admin areas get, since an hour-old answer is a poor
thing to accept for a change that permanent.

Both windows are settings rather than opinions, beside securityDisable where an
admin already decides how much of this they want.

Two things a proof must not do, and now cannot: a passkey belonging to somebody
else does not prove anything about the member holding the session, and neither
does signing in at a provider account that is not the one linked here. A
challenge issued to sign in cannot answer a step up either, or the reverse.

Signed-off-by: Mathias Albert <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants