password reset: an admin makes a one-hour link, and using it signs the account out everywhere
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / integration (push) Successful in 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 5s

There is no mail path, so a forgotten password needed a hand on the database
(#2939 §2). Settings → People lists the accounts; Reset password makes a link
that works once within an hour, shown once for the admin to hand over. Making
another link for the same account closes the earlier one.

Using it (/reset-password) sets the password, deletes the account's device
tokens, and moves users.session_epoch on. Sessions are signed cookies the
server can't delete, so each now carries the epoch it signed in under and
login_required reads the account's epoch by primary key. A cookie from before
this has no epoch and reads as 0, the starting value, so the upgrade signs
nobody out. A deleted account's session now stops working too.

The one-time link reveal moves out of InviteList into OneTimeLink, and the
link-building into router/links.ts, shared by invites and resets.

Migration 0033. #5173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 14:35:58 -04:00
co-authored by Claude Opus 5.5
parent 28fa8badcb
commit 3dd0b44cb9
17 changed files with 696 additions and 61 deletions
+107 -1
View File
@@ -29,6 +29,7 @@ from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
@@ -46,7 +47,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, invites, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, invites, password_resets, users"
@pytest_asyncio.fixture
@@ -1064,3 +1065,108 @@ async def test_two_people_racing_one_invite_cannot_both_get_in(app_client, db):
count = await fresh.scalar(select(func.count()).select_from(User))
assert count == 2, "the admin and exactly one of the two"
# --- Admin-issued password reset links (#5173) ---------------------------------
async def _admin_and_guest(app_client):
"""The admin, signed in on `app_client`, and a second account signed in on a
client of its own. Returns the guest's client and account id."""
token = await _admin_with_invite(app_client)
guest = create_app().test_client()
joined = await guest.post(
"/api/auth/register", json={"email": "guest@example.test", "password": _PASSWORD, "invite": token}
)
assert joined.status_code == 201
return guest, (await joined.get_json())["id"]
async def _reset_link(app_client, account_id: str) -> str:
resp = await app_client.post(f"/api/accounts/{account_id}/reset-link")
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["token"]
async def test_a_reset_link_sets_the_password_and_signs_the_account_out_everywhere(app_client, db):
guest, guest_id = await _admin_and_guest(app_client)
device = await guest.post("/api/auth/devices", json={"name": "Phone"})
bearer = {"Authorization": f"Bearer {(await device.get_json())['token']}"}
assert (await guest.get("/api/auth/me")).status_code == 200
assert (await create_app().test_client().get("/api/auth/me", headers=bearer)).status_code == 200
token = await _reset_link(app_client, guest_id)
browser = create_app().test_client()
reset = await browser.post("/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"})
assert reset.status_code == 200, await reset.get_data(as_text=True)
# The browser that used the link is signed in as the account it was made for.
assert (await (await browser.get("/api/auth/me")).get_json())["email"] == "guest@example.test"
# The session from before the reset is over, and so is the linked device.
assert (await guest.get("/api/auth/me")).status_code == 401
assert (await create_app().test_client().get("/api/auth/me", headers=bearer)).status_code == 401
# The new password signs in; the old one doesn't.
fresh = create_app().test_client()
old = await fresh.post("/api/auth/login", json={"email": "guest@example.test", "password": _PASSWORD})
assert old.status_code == 401
new = await fresh.post("/api/auth/login", json={"email": "guest@example.test", "password": "a-brand-new-password"})
assert new.status_code == 200
# The admin is untouched, and the link works once.
assert (await app_client.get("/api/auth/me")).status_code == 200
again = await create_app().test_client().post(
"/api/auth/reset-password", json={"token": token, "password": "yet-another-password"}
)
assert again.status_code == 403
assert (await again.get_json())["error"] == "invalid or expired reset link"
async def test_only_an_admin_lists_accounts_and_makes_reset_links(app_client, db):
guest, guest_id = await _admin_and_guest(app_client)
listed = (await (await app_client.get("/api/accounts")).get_json())["accounts"]
assert [(a["email"], a["is_admin"]) for a in listed] == [
("owner@example.test", True),
("guest@example.test", False),
]
assert (await guest.get("/api/accounts")).status_code == 403
assert (await guest.post(f"/api/accounts/{guest_id}/reset-link")).status_code == 403
assert (await app_client.post(f"/api/accounts/{uuid.uuid4()}/reset-link")).status_code == 404
assert (await app_client.post("/api/accounts/not-an-id/reset-link")).status_code == 404
async def test_an_expired_or_superseded_reset_link_is_refused(app_client, db):
_, guest_id = await _admin_and_guest(app_client)
first = await _reset_link(app_client, guest_id)
second = await _reset_link(app_client, guest_id)
async def use(token: str):
return await create_app().test_client().post(
"/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"}
)
# Making a second link closed the first.
assert (await use(first)).status_code == 403
async with session_scope() as fresh:
await fresh.execute(
update(PasswordReset).values(expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
)
await fresh.commit()
assert (await use(second)).status_code == 403
# Nothing changed: the old password still signs in.
signed = await create_app().test_client().post(
"/api/auth/login", json={"email": "guest@example.test", "password": _PASSWORD}
)
assert signed.status_code == 200
async def test_a_short_password_leaves_the_reset_link_usable(app_client, db):
_, guest_id = await _admin_and_guest(app_client)
token = await _reset_link(app_client, guest_id)
client = create_app().test_client()
short = await client.post("/api/auth/reset-password", json={"token": token, "password": "short"})
assert short.status_code == 400
ok = await client.post("/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"})
assert ok.status_code == 200