fix(auth): Google adoption revokes the password of a never-activated account

Without SMTP, registration marks the address verified with no proof, and an ADMIN_EMAILS /
ALLOWED_EMAILS address gets no invite row to gate it. So a stranger who registered such an address
(or a sibling spelling) before its owner kept their own password when the owner's first Google
sign-in adopted the row, activated it and made it admin, and could then sign in with it.

The adoption now treats a never-activated row like an unverified one: the password goes,
password_revoked_at is stamped and the session epoch bumped. An account already let in keeps its
password. The helper is renamed to say what it now judges (revoke_untrusted_preset_password).

test_google_callback.py drives the callback's account-matching branches through the real route,
none of which a test ran before: the squat (red on the old rule), adoption by mailbox, the
sub-collision and unverified-address refusals, the refused sign-in recorded in Google's locale, and
the link flow's conflict, mismatch and attach answers.
This commit is contained in:
2026-10-05 00:19:50 +02:00
parent fb962682eb
commit 1f357b0c21
5 changed files with 209 additions and 11 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# argon2id hash for email+password login; NULL for Google-only accounts.
password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes). Without
# auth.revoke_untrusted_preset_password; admin approval no longer revokes). Without
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
# password-reset eligibility check and could never be recovered — it could neither sign in nor
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
+1 -1
View File
@@ -151,7 +151,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
attacker can always register last — and it handed anonymous callers a write onto a real
account's credential. The owner's recourse is password-reset, which proves the mailbox, rewrites
the hash and bumps the session epoch. The residual is logged as SEC-16. The revoke also stays on
the Google-adoption path (auth.revoke_preset_password_if_unverified), where ownership is
the Google-adoption path (auth.revoke_untrusted_preset_password), where ownership is
actually attested."""
# Canonical, with the literal spelling preferred when both exist: approving
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
+14 -7
View File
@@ -442,9 +442,10 @@ async def callback(
return RedirectResponse(url=_access_notice_url("denied"))
user = existing
user.google_sub = sub
# SEC-10: drop any password pre-set on this account while its email was still unverified,
# now that Google attests ownership (bumps the epoch to revoke any session it minted).
revoke_preset_password_if_unverified(user)
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
# ownership (bumps the epoch to revoke any session it minted). Runs before the
# activation below, which is what it reads.
revoke_untrusted_preset_password(user)
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
@@ -1138,12 +1139,18 @@ def bump_session_epoch(user: User) -> None:
user.session_epoch = (user.session_epoch or 0) + 1
def revoke_preset_password_if_unverified(user: User) -> None:
def revoke_untrusted_preset_password(user: User) -> None:
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
so a legit no-SMTP registration is unaffected.
session it minted; the owner re-sets one via password-reset, or from Settings while the Google
session lasts. No-op for a verified account that was already let in.
A NEVER-ACTIVATED account is untrusted too, verified or not. Without SMTP, registration marks
the address verified with no proof at all, so "verified" says nothing about who chose the
password; activation is what an admin (or this very sign-in) grants. For an ADMIN_EMAILS /
ALLOWED_EMAILS address no invite row gates the registration, so a stranger who registered it
first would otherwise be activated here — as admin — with their own password still working.
The Google-adoption callback is the ONLY caller. Admin approval used to call this too, and
dropping it was right for the reason given — the revoke destroyed the password a legitimate
@@ -1165,7 +1172,7 @@ def revoke_preset_password_if_unverified(user: User) -> None:
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
was locked out for good (it could not sign in either). The stamp separates the two cases."""
if not user.email_verified:
if not user.email_verified or (not user.is_active and user.password_hash is not None):
user.password_hash = None
user.password_revoked_at = datetime.now(timezone.utc)
bump_session_epoch(user)