diff --git a/docs/public-hosting.md b/docs/public-hosting.md index 5198bb3..643f7d8 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -113,6 +113,12 @@ docker run --rm -v inkwell-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz on a note shared with you can't run script in your session. - **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in for CSRF protection: a `Lax` cookie is not sent on a cross-site POST. +- **Sessions can be ended from the Account page.** Changing your password there needs + the current one. It signs you out of every other browser and unlinks every app, + and you stay signed in where you made the change. **Sign out everywhere else** + does the same without changing the password. A browser session doesn't appear in + a list the way a linked app does: it is a signed cookie in that browser, ended by + moving the account on rather than by deleting a row. ## Email and forgotten passwords diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 67c5aa4..218317f 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -29,6 +29,8 @@ export const local: Repo = { login: () => Promise.reject(new Error("You're offline — there's no account to sign in to.")), register: () => Promise.reject(new Error("You're offline — accounts are created on a server.")), logout: () => Promise.resolve(), + changePassword: () => Promise.reject(new Error(NEEDS_SERVER)), + signOutElsewhere: () => Promise.reject(new Error(NEEDS_SERVER)), }, devices: { diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 8d19c19..f84349d 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -105,6 +105,15 @@ export interface AuthRepo { * while registration is closed. */ register(email: string, password: string, displayName: string, invite?: string): Promise; logout(): Promise; + /** Signs the account out of every other browser and unlinks every device; this + * browser stays signed in. Rejects with a 403 when `current` is wrong. */ + changePassword(current: string, next: string): Promise; + /** The same sign-out as `changePassword`, without changing the password. */ + signOutElsewhere(): Promise; +} + +export interface SignedOutElsewhere { + devices_unlinked: number; } export interface DevicesRepo { diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 221e9de..5f3f864 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -22,6 +22,7 @@ import type { ChecklistItemChanges, Repo, ServerSetting, + SignedOutElsewhere, } from "./repo"; // Render a board query to the GET /api/notes query string. Mirrors the param @@ -58,6 +59,9 @@ export const rest: Repo = { register: (email, password, displayName, invite) => api.post("/api/auth/register", { email, password, display_name: displayName, invite }), logout: () => api.post("/api/auth/logout"), + changePassword: (current, next) => + api.post("/api/auth/password", { current_password: current, new_password: next }), + signOutElsewhere: () => api.post("/api/auth/sign-out-elsewhere"), }, devices: { diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 8da3859..04f128d 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -346,8 +346,8 @@ async function signOut() { v-if="!desktopApp" to="/account" class="icon-btn hidden sm:inline-flex" - title="Linked devices" - aria-label="Linked devices" + title="Account" + aria-label="Account" > @@ -458,7 +458,7 @@ async function signOut() { >

{{ session.user?.display_name }}

- Linked devices + Account import("../views/AccountView.vue"), - meta: { title: "Linked devices", requiresAuth: true, requiresServer: true }, + meta: { title: "Account", requiresAuth: true, requiresServer: true }, }, { path: "/login", diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index e23db20..ddbdd07 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -37,5 +37,15 @@ export const useSessionStore = defineStore("session", () => { user.value = null; } - return { user, loaded, fetchMe, login, register, logout }; + // Both end every other session and unlink every device, and keep this one: the + // signed-in user doesn't change. Each resolves to how many devices were unlinked. + async function changePassword(current: string, next: string): Promise { + return (await repo.auth.changePassword(current, next)).devices_unlinked; + } + + async function signOutElsewhere(): Promise { + return (await repo.auth.signOutElsewhere()).devices_unlinked; + } + + return { user, loaded, fetchMe, login, register, logout, changePassword, signOutElsewhere }; }); diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index af35058..307a144 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -2,6 +2,7 @@ import { onMounted, ref } from "vue"; import { useConfigStore } from "../stores/config"; import { useDevicesStore } from "../stores/devices"; +import { useSessionStore } from "../stores/session"; import { useUiStore } from "../stores/ui"; import BaseButton from "../components/BaseButton.vue"; import BaseInput from "../components/BaseInput.vue"; @@ -11,9 +12,11 @@ import PageHeader from "../components/PageHeader.vue"; import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; -// Per-user (not admin) management of linked native clients — the Tauri desktop and -// Android apps authenticate sync with a device bearer token issued here. +// The signed-in person's own account (not admin): the native clients linked to it +// (the desktop and Android apps sync with a device bearer token issued here), its +// password, and signing out everywhere else (family idea #5105, practice 4). const devices = useDevicesStore(); +const session = useSessionStore(); const ui = useUiStore(); // Loaded here rather than in ClientDownloads: this view already awaits it, and a // component that fetches its own config would race the one that does. @@ -68,6 +71,50 @@ async function revoke(id: string, name: string) { } } +const currentPassword = ref(""); +const newPassword = ref(""); +const changing = ref(false); +const passwordError = ref(""); +const signingOut = ref(false); + +function unlinkedNote(n: number): string { + if (n === 0) return ""; + return n === 1 ? " 1 device was unlinked." : ` ${n} devices were unlinked.`; +} + +async function changePassword() { + changing.value = true; + passwordError.value = ""; + try { + const unlinked = await session.changePassword(currentPassword.value, newPassword.value); + currentPassword.value = ""; + newPassword.value = ""; + ui.showToast(`Password changed. You're signed out everywhere else.${unlinkedNote(unlinked)}`); + await devices.load(); + } catch (e) { + passwordError.value = errorMessage(e, "Couldn't change your password."); + } finally { + changing.value = false; + } +} + +async function signOutElsewhere() { + const ok = window.confirm( + "Sign out of every other browser and unlink every device? Each app will need to sign in again.", + ); + if (!ok) return; + signingOut.value = true; + try { + const unlinked = await session.signOutElsewhere(); + ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`); + await devices.load(); + } catch (e) { + ui.showToast(errorMessage(e, "Couldn't sign out everywhere else.")); + } finally { + signingOut.value = false; + } +} + onMounted(() => { void load(); }); @@ -75,8 +122,9 @@ onMounted(() => { diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index 4b91932..eb1ffa0 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -424,10 +424,7 @@ async def reset_password(): return json_error(INVALID_RESET, 403) user = await db.get(User, user_id) user.password_hash = hash_password(password) - user.session_epoch = User.session_epoch + 1 - unlinked = (await db.execute(delete(DeviceToken).where(DeviceToken.user_id == user_id))).rowcount - await db.commit() - await db.refresh(user) + unlinked = await _sign_out_elsewhere(db, user, keep_token=None) _sign_in(user) logger.info( "password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address() @@ -435,6 +432,84 @@ async def reset_password(): return jsonify(_serialize_user(user)) +async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int: + """End the account's sessions, and commit. Returns how many devices were unlinked. + + Web sessions end because the account's epoch moves on: they are signed cookies + in other browsers, out of reach. Linked devices are deleted, except the one + holding `keep_token`, when the request came from a device. The caller signs + this browser in again under the new epoch, if it should stay signed in. + """ + user.session_epoch = User.session_epoch + 1 + unlink = delete(DeviceToken).where(DeviceToken.user_id == user.id) + if keep_token: + unlink = unlink.where(DeviceToken.token_hash != hash_token(keep_token)) + unlinked = (await db.execute(unlink)).rowcount + await db.commit() + await db.refresh(user) + return unlinked + + +def _stay_signed_in(user: User) -> None: + """Keep the browser making this request signed in after `_sign_out_elsewhere`. A + device has no session to keep, and isn't given one.""" + if _session_user_id() is not None: + _sign_in(user) + + +@bp.post("/password") +@login_required +async def change_password(): + """Change the account's password, and sign it out everywhere else: every other + web session, and every linked device (family idea #5105, practice 4). The + browser that changed it stays signed in. + + The current password is required, so a session left open on someone else's + screen can't take the account over. A wrong one counts against the sign-in + budget, the same as a failed sign-in. + """ + data = await request.get_json(silent=True) or {} + current = data.get("current_password") or "" + password = data.get("new_password") or "" + if len(password) < MIN_PASSWORD_LEN: + return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400) + + async with session_scope() as db: + user = await db.get(User, g.user_id) + if user is None: + return json_error("authentication required", 401) + wait = _sign_in_block(user.email) + if wait is not None: + return _throttled(wait) + # 403, not 401: the caller IS signed in, and a 401 would read as "you were + # signed out" to anything that acts on it. + if not user.password_hash or not verify_password(current, user.password_hash): + _sign_in_failed(user.email) + logger.warning("password change refused (wrong password) email=%s from=%s", user.email, client_address()) + return json_error("your current password isn't right", 403) + user.password_hash = hash_password(password) + unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token()) + _stay_signed_in(user) + logger.info("password changed email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()) + return jsonify({"ok": True, "devices_unlinked": unlinked}) + + +@bp.post("/sign-out-elsewhere") +@login_required +async def sign_out_elsewhere(): + """Sign the account out of every other browser and unlink every device, keeping + this one signed in (family idea #5105, practice 4). For a session or a device + the person doesn't recognise, or one on a machine they no longer have.""" + async with session_scope() as db: + user = await db.get(User, g.user_id) + if user is None: + return json_error("authentication required", 401) + unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token()) + _stay_signed_in(user) + logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()) + return jsonify({"ok": True, "devices_unlinked": unlinked}) + + # --- Device (bearer) tokens for native clients — M8 sync hub --- diff --git a/tests/test_integration.py b/tests/test_integration.py index b938fdb..63c1c15 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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