Account page: change your password, or sign out everywhere else
Family idea #5105, practice 4. Either action signs the account out of every other browser and unlinks every device. The browser that made the change stays signed in. - POST /api/auth/password needs the current password. A wrong one returns 403, not 401, so this browser doesn't read as signed out, and it counts against the sign-in throttle. A short new password returns 400. - POST /api/auth/sign-out-elsewhere does the same sign-out without a password change. Called from a device, it keeps that device linked. - _sign_out_elsewhere moves session_epoch on and deletes device tokens. The reset route now uses it too, keeping no device. - The page is renamed from "Linked devices" to "Account", in the router title and both nav entries. Its sections are Linked devices, Password (one short line, then the form) and Sessions (a single "Sign out everywhere else" row in the device rows' style), per preference 188: one line each, no paragraphs. - docs/public-hosting.md says how sessions end, and why a browser session isn't listed the way a device is: it is a signed cookie, ended by moving the epoch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1200,6 +1200,92 @@ async def test_a_short_password_leaves_the_reset_link_usable(app_client, db):
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
|
||||
|
||||
|
||||
async def _elsewhere(app_client):
|
||||
"""The owner signed in on `app_client`, a second browser of theirs, and a linked
|
||||
device. Returns the second browser and the device's bearer header."""
|
||||
await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
|
||||
other = create_app().test_client()
|
||||
signed = await other.post("/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD})
|
||||
assert signed.status_code == 200
|
||||
device = await app_client.post("/api/auth/devices", json={"name": "Phone"})
|
||||
return other, {"Authorization": f"Bearer {(await device.get_json())['token']}"}
|
||||
|
||||
|
||||
async def _me(client, headers=None) -> int:
|
||||
return (await client.get("/api/auth/me", headers=headers or {})).status_code
|
||||
|
||||
|
||||
async def test_changing_the_password_signs_out_everywhere_but_here(app_client, db):
|
||||
other, bearer = await _elsewhere(app_client)
|
||||
resp = await app_client.post(
|
||||
"/api/auth/password", json={"current_password": _PASSWORD, "new_password": "a-brand-new-password"}
|
||||
)
|
||||
assert resp.status_code == 200, await resp.get_data(as_text=True)
|
||||
assert (await resp.get_json())["devices_unlinked"] == 1
|
||||
|
||||
# This browser stays signed in; the other one and the device are out.
|
||||
assert await _me(app_client) == 200
|
||||
assert await _me(other) == 401
|
||||
assert await _me(create_app().test_client(), bearer) == 401
|
||||
|
||||
fresh = create_app().test_client()
|
||||
old = await fresh.post("/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD})
|
||||
assert old.status_code == 401
|
||||
new = await fresh.post("/api/auth/login", json={"email": "owner@example.test", "password": "a-brand-new-password"})
|
||||
assert new.status_code == 200
|
||||
|
||||
|
||||
async def test_a_wrong_current_password_changes_nothing(app_client, db):
|
||||
other, bearer = await _elsewhere(app_client)
|
||||
wrong = await app_client.post(
|
||||
"/api/auth/password", json={"current_password": "not-the-password", "new_password": "a-brand-new-password"}
|
||||
)
|
||||
# 403, not 401: this browser is still signed in, and must not read as signed out.
|
||||
assert wrong.status_code == 403
|
||||
short = await app_client.post("/api/auth/password", json={"current_password": _PASSWORD, "new_password": "short"})
|
||||
assert short.status_code == 400
|
||||
|
||||
assert await _me(app_client) == 200
|
||||
assert await _me(other) == 200
|
||||
assert await _me(create_app().test_client(), bearer) == 200
|
||||
signed = await create_app().test_client().post(
|
||||
"/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD}
|
||||
)
|
||||
assert signed.status_code == 200
|
||||
|
||||
|
||||
async def test_sign_out_elsewhere_keeps_this_browser_and_the_password(app_client, db):
|
||||
other, bearer = await _elsewhere(app_client)
|
||||
resp = await app_client.post("/api/auth/sign-out-elsewhere")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["devices_unlinked"] == 1
|
||||
|
||||
assert await _me(app_client) == 200
|
||||
assert await _me(other) == 401
|
||||
assert await _me(create_app().test_client(), bearer) == 401
|
||||
signed = await create_app().test_client().post(
|
||||
"/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD}
|
||||
)
|
||||
assert signed.status_code == 200
|
||||
|
||||
|
||||
async def test_sign_out_elsewhere_from_a_device_keeps_that_device(app_client, db):
|
||||
other, bearer = await _elsewhere(app_client)
|
||||
second = await app_client.post("/api/auth/devices", json={"name": "Laptop"})
|
||||
laptop = {"Authorization": f"Bearer {(await second.get_json())['token']}"}
|
||||
|
||||
resp = await create_app().test_client().post("/api/auth/sign-out-elsewhere", headers=bearer)
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["devices_unlinked"] == 1
|
||||
assert await _me(create_app().test_client(), bearer) == 200
|
||||
assert await _me(create_app().test_client(), laptop) == 401
|
||||
assert await _me(app_client) == 401
|
||||
assert await _me(other) == 401
|
||||
|
||||
|
||||
async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_client, db):
|
||||
"""A session-cookie caller holds no device token, so "revoke the one I'm using"
|
||||
has nothing to name. Moved here from the unit lane when the session check began
|
||||
|
||||
Reference in New Issue
Block a user