Design option C, chosen by the user 2026-09-05. Nothing in the admin UI ever showed an account's GATE STATE: an admin could see a "Pending approval" chip but not whether the address was confirmed, whether SEC-10 had cleared the password, or which Google account was attached — so every support conversation started by guessing. Clicking a row now opens a drawer that says which gate the person is actually stuck behind, with the recovery actions under that readout rather than in the row. The drawer is `Modal placement="drawer"`, not a second dialog: the layer stack, ESC, the focus trap, Back-to-close and the scroll lock are the hard parts, and duplicating them is how two shells drift apart. It slides in, and honours prefers-reduced-motion like the rest. The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone forgot theirs") reuses the existing token machinery and the SAME `#reset=` entry the mailed link uses, so the signed-out card consumes it with no new flow. It is a distinct token KIND for one reason: a mailed reset arrives in the mailbox, so completing it proves ownership and confirms the address; an admin-issued link proves nothing about the mailbox — the admin hands it over by whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would be the same false attestation SEC-15 is about. Issuing does not revoke the current password: a misclick must not lock anyone out. USING it rewrites the hash and bumps the session epoch, which is what evicts a squatter — SEC-16's actual remedy. Spending either kind burns the siblings of both, so a completed reset cannot leave a live admin link behind. The non-consuming probe learned the new kind too, or every admin link would have opened onto "this link is spent". The audit log records that a link was issued, by whom, for whom — never the link, which is a live credential-setting secret and the last thing that belongs in a long-lived log a human reads. Not gated on account state on purpose: it is useful for a pre-empted address, a revoked password, a Google-only account that wants one, and a plain forgotten password. Narrowing it would only make it refuse exactly when the admin needs it. The shared demo account is the one exception. Verified on localdev, both languages, end to end: issue from the panel -> the signed-in tab shows the honest "reset links open in a signed-out window" banner with the token still in the address bar -> signing out draws the "Set a new password" form, which is only possible because the probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""E12 · S5 — the admin-issued set-password link.
|
|
|
|
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
|
|
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
|
|
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
|
|
|
|
The property that must not regress is the one that makes it honest: an admin-issued link proves
|
|
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
|
|
confirm the address the way a mailed reset does.
|
|
"""
|
|
|
|
from app.models import AuthToken
|
|
|
|
|
|
def _link(client_admin, user_id: int):
|
|
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["url"].split("#reset=", 1)[1]
|
|
|
|
|
|
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
|
|
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
|
|
it proves ownership and confirms the address. An admin-issued link does not, and claiming
|
|
otherwise would let an admin confirm an address nobody ever proved."""
|
|
admin = make_user(role="admin")
|
|
target = make_user(email_verified=False)
|
|
db.commit()
|
|
|
|
token = _link(as_user(admin), target.id)
|
|
r = client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
db.refresh(target)
|
|
assert target.password_hash is not None
|
|
assert target.email_verified is False # the address is still unproven — and says so
|
|
|
|
|
|
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
|
|
"""The mutation check for the test above: if `email_verified` were simply never set, this would
|
|
fail — and the rescue path for "registered but never clicked the link" would be gone."""
|
|
from datetime import timedelta
|
|
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(email_verified=False)
|
|
db.commit()
|
|
token = _issue_token(db, user, "reset", timedelta(hours=1))
|
|
|
|
r = client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
db.refresh(user)
|
|
assert user.email_verified is True
|
|
|
|
|
|
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
|
|
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
|
|
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
|
|
than writing a second one that could forget to bump."""
|
|
target = make_user()
|
|
db.commit()
|
|
before = target.session_epoch
|
|
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
token = _link(as_user(admin), target.id)
|
|
client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
|
|
)
|
|
|
|
db.refresh(target)
|
|
assert target.session_epoch != before
|
|
|
|
|
|
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
|
|
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
|
|
anything — which is also why it is safe to offer on a perfectly healthy account."""
|
|
target = make_user()
|
|
db.commit()
|
|
original = target.password_hash
|
|
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
_link(as_user(admin), target.id)
|
|
|
|
db.refresh(target)
|
|
assert target.password_hash == original
|
|
assert target.password_revoked_at is None
|
|
|
|
|
|
def test_the_link_is_single_use(db, as_user, make_user, client):
|
|
target = make_user()
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
|
|
token = _link(as_user(admin), target.id)
|
|
first = client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
|
|
)
|
|
second = client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
|
|
)
|
|
assert first.status_code == 200
|
|
assert second.status_code == 400
|
|
|
|
|
|
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
|
|
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
|
|
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
|
|
target = make_user()
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
|
|
first = _link(as_user(admin), target.id)
|
|
second = _link(as_user(admin), target.id)
|
|
|
|
ok = client.post(
|
|
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
|
|
)
|
|
assert ok.status_code == 200
|
|
stale = client.post(
|
|
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
|
|
)
|
|
assert stale.status_code == 400
|
|
|
|
|
|
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
|
|
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
|
|
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
|
|
onto "this link is spent"."""
|
|
target = make_user()
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
|
|
token = _link(as_user(admin), target.id)
|
|
r = client.post("/auth/password-reset/check", json={"token": token})
|
|
assert r.json() == {"ok": True}
|
|
|
|
|
|
def test_only_an_admin_can_mint_one(db, as_user, make_user):
|
|
plain = make_user()
|
|
victim = make_user()
|
|
db.commit()
|
|
|
|
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
|
|
assert r.status_code in (401, 403)
|
|
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
|
|
|
|
|
|
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
|
|
"""The demo account is shared, so handing anyone a way to change its password hands them
|
|
everyone else's session on it."""
|
|
admin = make_user(role="admin")
|
|
demo = make_user(is_demo=True)
|
|
db.commit()
|
|
|
|
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
|
|
assert r.status_code == 400
|
|
assert r.json()["detail"]["reason"] == "demo"
|
|
|
|
|
|
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
|
|
admin = make_user(role="admin")
|
|
target = make_user(email_verified=False, is_active=False)
|
|
db.commit()
|
|
|
|
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
|
|
assert body["email_verified"] is False
|
|
assert body["is_active"] is False
|
|
assert set(body) >= {
|
|
"is_allowed",
|
|
"can_activate",
|
|
"email_configured",
|
|
"password_revoked_at",
|
|
"google_email",
|
|
"has_password",
|
|
"has_google",
|
|
}
|
|
|
|
|
|
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
|
|
""" "Connected" and "connected as someone else" are different statements, and only the second one
|
|
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
|
|
admin = make_user(role="admin")
|
|
same = make_user(email="same@example.com")
|
|
same.google_email = "same@example.com"
|
|
other = make_user(email="typed@example.com")
|
|
other.google_email = "google-primary@example.com"
|
|
db.commit()
|
|
|
|
c = as_user(admin)
|
|
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
|
|
assert (
|
|
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
|
|
== "google-primary@example.com"
|
|
)
|
|
|
|
|
|
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
|
|
"""The audit log is read by humans and kept long; a live credential-setting link is the last
|
|
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
|
|
from app.models import AuditLog
|
|
|
|
admin = make_user(role="admin")
|
|
target = make_user()
|
|
db.commit()
|
|
|
|
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
|
|
secret = url.split("#reset=", 1)[1]
|
|
|
|
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
|
|
assert len(rows) == 1
|
|
assert rows[0].summary == target.email
|
|
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
|
|
|
|
|
|
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
|
|
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
|
|
and narrowing this would only make it refuse exactly then."""
|
|
admin = make_user(role="admin")
|
|
google_only = make_user(password=None)
|
|
db.commit()
|
|
assert google_only.password_hash is None
|
|
|
|
token = _link(as_user(admin), google_only.id)
|
|
r = client.post(
|
|
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
|
|
)
|
|
assert r.status_code == 200
|
|
db.refresh(google_only)
|
|
assert google_only.password_hash is not None
|
|
|
|
|
|
def test_an_unknown_user_is_a_404(db, as_user, make_user):
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
|
|
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
|
|
|
|
|
|
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
|
|
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
|
|
someone a working link."""
|
|
admin = make_user(role="admin")
|
|
target = make_user()
|
|
db.commit()
|
|
|
|
raw = _link(as_user(admin), target.id)
|
|
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
|
|
assert len(rows) == 1
|
|
assert rows[0].token_hash != raw
|
|
assert raw not in rows[0].token_hash
|
|
|
|
|
|
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
|
|
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
|
|
one hour is far too short."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
admin = make_user(role="admin")
|
|
target = make_user()
|
|
db.commit()
|
|
|
|
_link(as_user(admin), target.id)
|
|
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
|
|
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
|
|
|
|
|
|
def test_users_listing_still_serializes(db, as_user, make_user):
|
|
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
|
|
endpoint exists to keep out of it."""
|
|
admin = make_user(role="admin")
|
|
db.commit()
|
|
rows = as_user(admin).get("/api/admin/users").json()
|
|
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
|
|
assert "can_activate" not in rows[0]
|