Skip to content

[3.0] External authentication (part 1 of 5) — lets something other than a password log a member in - #9381

Open
albertlast wants to merge 2 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-foundation
Open

[3.0] External authentication (part 1 of 5) — lets something other than a password log a member in#9381
albertlast wants to merge 2 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-foundation

Conversation

@albertlast

@albertlast albertlast commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Description

Feature series: external authentication, part 1 of 3. These belong together and are
not bug fixes:

part 1 this PR groundwork: makes login reusable by something other than the password form
part 2 to come OpenID Connect sign in (Google, Microsoft, Keycloak, …)
part 3 to come passkeys (WebAuthn), as a passwordless first factor and as a second factor

Parts 2 and 3 are independent of each other; both sit on this one. Nothing here is user
visible on its own.


Groundwork so that something other than the password form can log a member in. No new
feature here, and nothing user visible on a stock install — this is the part that OpenID
Connect and passkeys would both otherwise have to work around, split out so it can be
reviewed on its own.

Three things currently assume the password form:

  1. The steps after a successful check are unreachable. Login2::DoLogin() calls
    integrate_login, sets the cookie, resets the flood counter, enforces bans, writes
    member_logins and redirects — but it is protected and reads $this->member, so
    anything else authenticating a member has to reimplement it and will quietly miss a
    step. Moved to Login2::completeLogin($member, $stay_logged_in, $redirect);
    DoLogin() is now a one line caller, so the password path is byte for byte the same.
  2. Two factor authentication is tfa_secret. Every place that asks "does this member
    have a second factor" reads that column. Added User::getSecondFactors(), which
    reports what they have and lets a mod register its own via integrate_second_factors.
    It reads the loaded profile rather than object properties, because verifyTfa() runs
    before setProperties() has populated anything.
  3. Every account is assumed to have a password. Added
    User::hasUsablePassword(). The login form now refuses such an account before
    checkPasswordFallbacks() compares the submitted password against an empty hash, and
    validateSession() offers integrate_reauthenticate so a member who signs in some
    other way is not simply locked out of the admin and moderation areas. Nothing in SMF
    creates such an account yet; this is the seam for whatever does.

Also adds a member_auth table for the credentials such accounts would sign in with
(deleted along with the member), and a slot on the login form that renders whatever
registers through integrate_authentication_methods.

One behaviour change worth flagging. Login2::checkCookie() now checks tfa_mode as
well as the member, which User::verifyTfa() already did. Previously a member with a
tfa_secret on a forum with TFA switched off was redirected to ?action=logintfa, where
nothing was going to ask them for a code — they got "You are not allowed to access this
section" instead of being logged in. They now log in normally. This is not a weakening:
with tfa_mode off, verifyTfa() was not enforcing anything either way.

New hooks: integrate_second_factors, integrate_authentication_methods,
integrate_reauthenticate. Each is documented at its call site, since there is no central
hook list to add them to.

Testing. A 14 check regression run over the login paths this touches — form renders,
correct and incorrect password, remember me and its cookie lifetime, logout, the admin
password re-prompt and passing it, a member with TOTP being held at the second factor, and
an account with an empty passwd being refused — passes identically on this branch and on
unmodified release-3.0, which is the point for a change that is meant to alter nothing.
Run on both MySQL and PostgreSQL. The new table was checked on both engines, created by
the installer on a fresh install (73 tables) and by the migration on an existing one. The
two new extension points were exercised with a throwaway mod: a registered method renders
on the login form, and a registered factor holds back a member who has no tfa_secret at
all.

Noted while testing, not touched here: ?action=logintfa is already broken on
release-3.0 independently of this. LoginTFA::execute() does
User::load(..., dataset: User::$me->dataset), but verifyTfa() has just reset the member
to a guest, whose dataset is null, so it dies with "Cannot assign null to property
SMF\User::$dataset". Reproduced identically with and without this branch. Worth its own PR.

Issues References (Fixes|Related|Closes)

  1. Groundwork for OpenID Connect and passkey support

Merge order (added after the series was complete)

The parts are split for review, not for merging independently. Two of them make the
forum worse on their own, so this is worth being explicit about:

PR Safe to merge alone?
#9380, #9490 Yes. Plain bug fixes, no relation to this series.
#9381 (part 1) Yes. Nothing it adds is reachable by a user: the table stays empty, no account can exist without a password, and the new hooks and the login-form slot do nothing until a mod fills them.
#9488 (part 2) No — needs #9492.
#9489 (part 3) No — needs #9492.
#9491 (part 4) No — needs #9492.
#9492 (part 5) Closes what 2, 3 and 4 open.

Why 2 and 3 are not safe alone. Today a member who thinks their session has been
stolen has two levers that work: change the password, or log out. Changing the password
changes passwd, and the login cookie is an HMAC over it, so every other cookie dies;
logging out rotates password_salt (Logout.php:123),
which kills them too. Parts 2 and 3 let anyone holding the session attach a passkey or
link a provider account from the profile, and neither lever touches that — it is a row
in member_auth, and the attacker signs in freshly with a credential of their own. So
those two PRs, merged without part 5, remove the forum's existing ability to evict
somebody. Part 5 is what puts a re-authentication check in front of adding one.

Why 4 is not safe alone. It 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. Part 5 gives that prompt
an answer.

So: part 1 can land whenever. Parts 2 to 5 want to land together, or at least part 5 must
not lag behind the others.

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>
@albertlast

Copy link
Copy Markdown
Collaborator Author

@Sesquipedalian you mention you would like to see passkeys to work this would be the base of this idea,
some addiotnal pr will be in the pipeline

/****************
* Public methods
****************/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
*
*/
public function isCandidate(): bool
{
$tables = Db::$db->list_tables();
$member_auth = new Schema\v3_0\MemberAuth();
return \in_array(Config::$db_prefix . $member_auth->name, $tables);
}

Feature of the migration logic is that we can check if we need to perform this action, which helps against multiple run scenarios. In this case, because the table exists, we don't. The upgrader if re-run, will indicate "skip" on this step, since it no longer meets the qualifications to run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, added in 95904e9 — though I inverted it relative to the snippet.

isCandidate() is true when the step should run (Upgrade.php: if (!$substep->isCandidate()) → skip), so for a create-table step it needs to be false once the table is there. As written, return \in_array(...) would skip creating the table on the upgrade that needs it and run only where it already existed. v2_1\MembersOpenID shows the same polarity the other way round: it returns true when openid_uri is still present, because it drops it. Your description says exactly this, so I read it as a slip in the snippet rather than the intent.

The Config::$db_prefix part I kept exactly as you had it, and it is worth calling out: Db::$db->prefix is database qualified — it comes back as smf`.smf_ — while list_tables() reports bare names, so the two never match. Checked on this install: Config::$db_prefix . 'member_auth' matches, Db::$db->prefix . … does not, and Table::exists() returns false for a table that is demonstrably there. Left a comment on the method so the next person does not reach for the other one.

Verified all three states: table present → skipped, dropped → runs, after running → skipped again.

@jdarwood007 jdarwood007 added this to the 3.0 Alpha 6 milestone Aug 8, 2026
@albertlast albertlast changed the title [3.0] Lets something other than a password log a member in [3.0] External authentication (part 1 of 3) — lets something other than a password log a member in Aug 10, 2026
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>
@albertlast albertlast changed the title [3.0] External authentication (part 1 of 3) — lets something other than a password log a member in [3.0] External authentication (part 1 of 4) — lets something other than a password log a member in Aug 10, 2026
@albertlast albertlast changed the title [3.0] External authentication (part 1 of 4) — lets something other than a password log a member in [3.0] External authentication (part 1 of 5) — lets something other than a password log a member in Aug 10, 2026
@albertlast

Copy link
Copy Markdown
Collaborator Author

So the idea is done, would recommand to merge the bug fix asap, and the general idea in a later alpha release.

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