Skip to content

[3.0] External authentication (part 4 of 5) — lets people sign up without a password - #9491

Open
albertlast wants to merge 6 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-signup
Open

[3.0] External authentication (part 4 of 5) — lets people sign up without a password#9491
albertlast wants to merge 6 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-signup

Conversation

@albertlast

@albertlast albertlast commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Do not merge without #9492. This is the first thing that creates an account with no password, which makes the User::validateSession() prompt unanswerable — such a member is shut out of the administration and moderation areas permanently. #9492 gives that prompt an answer. See the merge order note on #9381.

Description

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

The first three parts all assumed the account was already there. That leaves the
obvious hole: somebody who has never registered here.

  • An identity provider vouches for a stranger, SMF sends them to ?action=signup
    and the form asks them to invent a password, which is the one thing they came
    here not to have. registerMember() hard-errors on an empty one.
  • A passkey could not create an account at all. The only way to get one was to
    already have an account and a password to sign in with.

So the two features shipped so far can sign an existing member in, and nothing
more. This closes that.

Accounts with no password. registerMember() gains a passwordless option:
it skips the "you must choose a password" error and stores an empty passwd.
Nothing hashes to an empty string, so no password can ever match it — that is
the marker User::hasUsablePassword() has looked for since part 1. The option
is never read from the request; only a caller that has already arranged some
other way in asks for it.

The sign up form. When a provider has vouched, the username and email are
filled in from the claims, the password boxes are gone, and the form says who
vouched. When passkeys are offered, a button appears beside the password boxes;
pressing it runs the creation ceremony there and then, the verified credential
waits in the session, and the boxes are replaced. The button is added by the
script, so a browser without the API never sees a dead control and still gets
the ordinary form.

Nothing is created behind anybody's back. Being vouched for gets you to the
sign up form and no further: the agreement, the privacy policy, COPPA, the
verification image and admin approval all still apply, which is the whole reason
this hands over to the ordinary form rather than inserting a row. The credential
is attached after registerMember() returns an ID, and if the member never
finishes, it is forgotten with the session.

One wrinkle worth knowing about. A passkey made before the account exists
has no member ID to derive its WebAuthn user handle from, so it carries a random
one. That handle is stored with the credential and reused by every passkey the
member adds later — otherwise the browser would group them separately and show
one account as two.

New setting, on Admin → Server → Authentication → Passkeys:
webauthn_allow_signup, off by default. Letting members add a passkey to an
account they already have is a smaller decision than letting an account exist
with no password at all, so it is a separate box. The OIDC side already had
allow_registration per provider, from part 2; AuthExternal now also checks
whether the forum is taking new members at all, so a closed forum says so
instead of bouncing them to a form that refuses them.

Recovery. An account with no password is recovered the way every SMF account
is: the emailed reset at ?action=reminder, which turns it into an ordinary
account with a password. Worth flagging that that path is broken on release-3.0
today and #9380 fixes it — it matters more once accounts can exist with no
password to begin with.

Not changed: admin-created accounts still get a generated password, and the
welcome emails are untouched (none of the guest-facing templates prints
{PASSWORD}; a customised one that does would get an empty string).

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. 76 checks, all passing.

Passkey sign up (45 checks):

  • the form offers the passkey and hides the offer until the script decides
  • the ceremony returns options naming this forum, a 32-byte handle and a
    discoverable credential
  • nothing is written to member_auth before the account exists
  • the form afterwards drops the password boxes and says the passkey is ready
  • the account is created with passwd = '' and a salt, activated, and signed in
  • the credential is attached to it, under the hashed credential ID, with the key
    and the handle kept
  • the passkey then signs them in from a fresh browser
  • a second passkey added from the profile reuses the sign up's handle and
    excludes the first
  • '', the username and password are all refused as passwords
  • an ordinary password sign up still works and still gets a password
  • refused: no username, no session token, a signed in member, the setting off

OIDC sign up (31 checks):

  • the provider button, the authorize redirect, the provider's own login form and
    the callback
  • lands on the agreement, then a form with the username and email filled in from
    the claims, no password boxes, and Keycloak named
  • no account created until the form is submitted
  • the account is created with no password, and the identity attached to it
  • coming back a second time signs them straight in, with one account, not two
  • passwords still refused; the reminder page does not fall over on an account
    with no password
  • with allow_registration off, no sign up form and a message that says why

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

Issues References (Fixes|Related|Closes)

Related: #9381, #9488, #9489, #9380

albertlast and others added 6 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>
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>
@albertlast albertlast changed the title [3.0] External authentication (part 4 of 4) — lets people sign up without a password [3.0] External authentication (part 4 of 5) — lets people sign up without a password Aug 10, 2026
@jdarwood007 jdarwood007 added this to the 3.0 Alpha 6 milestone Aug 10, 2026
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