fix(auth): keep the address the user registered with, and let it sign them in (E9.5 S3)

Two halves of one rule: the address someone typed is theirs to keep, and it must be
the one that gets them back in.

A plus-tagged or dotted Gmail is often a deliberate choice -- registering as
me+siftlode@gmail.com is how you find out later which site leaked your address. The
Google callback overwrote it with the account's primary, which can never contain a
'+', so the tag vanished on the first Google sign-in and, because sign-in matched the
stored address, took their chosen address with it. The rewrite line is older than
this epic, but S3 made it reachable here by matching adoption across spellings. It
now runs only when the mailbox GENUINELY changed, and lives in a named helper rather
than inline, so the rule has one home.

The three lookups that resolve a typed address -- password login, password reset,
verify resend -- go through find_user_by_email: exact spelling first, then the unique
canonical candidate, and None when several accounts share a mailbox rather than
guessing which one a password should open. Reset and resend previously answered a
different spelling with silence (they reply uniformly by design), which was a dead end
with no way out. Their mail now goes to the address on the account, never the one
typed into the form.
This commit is contained in:
2026-09-04 01:07:54 +02:00
parent 876cece881
commit c74487fbb3
3 changed files with 202 additions and 12 deletions
+137
View File
@@ -156,3 +156,140 @@ def test_manually_whitelisting_another_spelling_reuses_the_row(db):
rows = db.execute(select(Invite)).scalars().all()
assert len(rows) == 1
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
def _capture_reset_links(monkeypatch) -> list[str]:
"""Patch the reset mailer and collect the recipients it is asked to write to."""
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to))
return sent
# --- signing in and recovering an account, whichever spelling of it the person types -------------
#
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
# total silence from password-reset and verify-resend, which answer uniformly by design.
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
from app.auth import find_user_by_email
make = User(
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
)
db.add(make)
db.commit()
for typed in (
"t.e.st+tag@gmail.com", # as registered
"test@gmail.com", # the mailbox itself
"t.e.st@gmail.com", # dots only
"TEST@googlemail.com", # the alias domain
):
assert find_user_by_email(db, typed) is make, typed
def test_an_unrelated_address_still_finds_nothing(db):
from app.auth import find_user_by_email
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "someoneelse@gmail.com") is None
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
# their own account, not on whichever row happens to come first.
from app.auth import find_user_by_email
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
db.add_all([dotted, plain])
db.commit()
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
# them at random would let someone into the wrong account, so this answers "no such account" —
# the same uniform reply the caller already gives.
from app.auth import find_user_by_email
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
"""Typing another spelling must still reach the owner — and the mail must go to the address they
registered with, tag and all, not to the one typed into the form."""
from app.routes.auth import _send_reset_if_eligible
db.add(
User(
email="me+siftlode@gmail.com",
email_verified=True,
is_active=True,
password_hash="x",
)
)
db.commit()
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("me@gmail.com")
assert sent == ["me+siftlode@gmail.com"]
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
Google returns the account's primary address, which can never contain `+`, so following it
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
address first, stopped them signing in with the address they had chosen."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "me@gmail.com")
assert user.email == "me+siftlode@gmail.com"
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
from app.routes.auth import _sync_google_email
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "brand-new@example.com")
assert user.email == "brand-new@example.com"
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
from app.routes.auth import _sync_google_email
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "taken@example.com")
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in