Skip to content

[3.0] External authentication (part 3 of 5) — lets members sign in with a passkey - #9489

Open
albertlast wants to merge 5 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-passkeys
Open

[3.0] External authentication (part 3 of 5) — lets members sign in with a passkey#9489
albertlast wants to merge 5 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-passkeys

Conversation

@albertlast

@albertlast albertlast commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Do not merge without #9492. On its own this PR lets anyone holding a member's session attach a permanent new way into their account, which a password change and a logout both fail to revoke — so it removes an eviction lever the forum has today. #9492 puts a re-authentication check in front of it. See the merge order note on #9381.

Description

Part 3 of a series that gives SMF a way to sign in that is not a password.
Part 1 (#9381) made the login completion reusable and added the credential
table; part 2 (#9488) added OpenID Connect. This part adds passkeys.

A passkey is a key pair the member's device makes and keeps. Registering one
means the forum is handed the public half; signing in means being handed a
signature and checking it against the half we kept. It is unphishable: the
browser writes the origin into what gets signed, and a site pretending to be
this forum cannot make it write ours.

What a member sees. A "Passkeys" area in their profile where they can add
one and name it, and a "Sign in with a passkey" button on the login form —
plus the passkey offered directly in the username field's autofill list, on
browsers that support conditional mediation. Neither appears unless the
browser actually has the API, because the script adds them rather than the
template printing something that would be dead for everyone else.

What an admin sees. A new Authentication area in the ACP, with the
existing provider list moved under it and a Passkeys page beside it. Off by
default. The page states which domain passkeys will be tied to, since that is
taken from the forum URL and changing it later invalidates every passkey with
no way to move them.

Implementation notes, and the arguments behind them:

  • Bundled, not a dependency. Sources/WebAuthn/ follows what
    Sources/TOTP/ already does. It is a CBOR reader, a COSE key to PEM
    converter, the authenticator data structure, and the two ceremonies —
    small, because only what authenticators actually send is supported and
    anything else throws rather than being guessed at.
  • Two algorithms. ES256 and RS256 are the only ones offered, because they
    are the only ones we can verify. Offering more would mean accepting a key
    we cannot check.
  • No attestation. It is neither requested nor examined. Attestation says
    what make of authenticator produced a credential, which only matters to a
    site policing a list of approved models; verifying it means shipping trust
    anchors and keeping them current, and getting it wrong locks out honest
    members. None of the security of signing in rests on it.
  • openssl stays optional. The feature hides itself when the extension is
    missing rather than raising what SMF requires everywhere.
  • Discoverable credentials are required, since that is what lets the browser
    offer the passkey before anyone has said who they are.
  • User verification is required by default, and the setting is the opt-out,
    so the safe answer is the one an install gets without anyone choosing it.
  • Credential IDs are stored hashed. They run to a kilobyte and MySQL indexes
    only the first 191 characters of the column, so two different credentials
    could collide on the indexed part. The ID itself is kept beside the key.
  • A sign counter that has not moved forward is logged, not acted on. It is
    the one hint the standard gives that a credential was cloned, but plenty of
    authenticators never count at all, so refusing would lock members out over
    a quirk.

Not in this part: passkeys as a second factor alongside TOTP. That lives
behind ?action=logintfa, which cannot render on release-3.0 at all —
LoginTFA::execute() reads User::$me->dataset after verifyTfa() has reset
the member to a guest, and a guest's dataset is null. That is a separate
bug, unrelated to this series, and worth its own fix first.

Verified, since CI only proves the code parses:

  • 66 checks over the primitives with no browser involved: CBOR decoding
    including the malformed cases it must refuse, COSE to PEM for both
    algorithms — the rebuilt PEM is byte for byte what openssl produces for the
    same key — signature verification, authenticator data parsing including
    finding where the public key ends when extensions follow it, and both
    ceremonies against a forged authenticator.
  • 43 checks over real HTTP against a running forum with a synthetic
    authenticator: registering, listing, the duplicate refusal, the exclusion
    list, passwordless sign-in ending logged in as the right member, the sign
    counter written back, removal, and the admin page. Refusals cover another
    origin, another relying party, a key we never saw, an unregistered
    credential ID, a challenge already spent on a failed attempt, an assertion
    answered in a different browser session, and a ceremony with no session
    token — none of which logged anybody in.
  • composer lint, phplint on the whole tree, and the four integrity checks.

Note that composer lint only looks at tracked files that git already knows
changed, so it silently skips new files; these were checked by pointing
php-cs-fixer at them directly.

One fix outside the new code: Credential::listFor() did not select
secret_data, so anything reading it back got nothing. It is what the
exclusion list is built from, and the test above is how it was found.

Issues References (Fixes|Related|Closes)

  1. Related: [3.0] External authentication (part 1 of 5) — lets something other than a password log a member in #9381 (part 1 of 4), [3.0] External authentication (part 2 of 5) — lets members sign in with an OpenID Connect provider #9488 (part 2 of 4)
  2. Related: [3.0] Fixes password reminders never validating #9380, the reminder fix this series was built on top of

albertlast and others added 5 commits August 10, 2026 08:15
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>
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