[3.0] External authentication (part 1 of 5) — lets something other than a password log a member in - #9381
Conversation
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>
|
@Sesquipedalian you mention you would like to see passkeys to work this would be the base of this idea, |
| /**************** | ||
| * Public methods | ||
| ****************/ | ||
|
|
There was a problem hiding this comment.
| /** | |
| * | |
| */ | |
| 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.
There was a problem hiding this comment.
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.
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>
|
So the idea is done, would recommand to merge the bug fix asap, and the general idea in a later alpha release. |
Description
Feature series: external authentication, part 1 of 3. These belong together and are
not bug fixes:
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:
Login2::DoLogin()callsintegrate_login, sets the cookie, resets the flood counter, enforces bans, writesmember_loginsand redirects — but it isprotectedand reads$this->member, soanything 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.tfa_secret. Every place that asks "does this memberhave a second factor" reads that column. Added
User::getSecondFactors(), whichreports 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()runsbefore
setProperties()has populated anything.User::hasUsablePassword(). The login form now refuses such an account beforecheckPasswordFallbacks()compares the submitted password against an empty hash, andvalidateSession()offersintegrate_reauthenticateso a member who signs in someother 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_authtable 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 checkstfa_modeaswell as the member, which
User::verifyTfa()already did. Previously a member with atfa_secreton a forum with TFA switched off was redirected to?action=logintfa, wherenothing 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_modeoff,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 centralhook 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
passwdbeing refused — passes identically on this branch and onunmodified
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_secretatall.
Noted while testing, not touched here:
?action=logintfais already broken onrelease-3.0independently of this.LoginTFA::execute()doesUser::load(..., dataset: User::$me->dataset), butverifyTfa()has just reset the memberto a guest, whose
datasetis null, so it dies with "Cannot assign null to propertySMF\User::$dataset". Reproduced identically with and without this branch. Worth its own PR.
Issues References (Fixes|Related|Closes)
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:
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. Sothose 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 isshut 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.