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
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:
+107
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user