AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
10 changed files with 293 additions and 13 deletions
Showing only changes of commit bb591871a4 - Show all commits
+6
View File
@@ -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
+2
View File
@@ -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: {
+9
View File
@@ -105,6 +105,15 @@ export interface AuthRepo {
* while registration is closed. */
register(email: string, password: string, displayName: string, invite?: string): Promise<User>;
logout(): Promise<void>;
/** 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<SignedOutElsewhere>;
/** The same sign-out as `changePassword`, without changing the password. */
signOutElsewhere(): Promise<SignedOutElsewhere>;
}
export interface SignedOutElsewhere {
devices_unlinked: number;
}
export interface DevicesRepo {
+4
View File
@@ -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<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
logout: () => api.post<void>("/api/auth/logout"),
changePassword: (current, next) =>
api.post<SignedOutElsewhere>("/api/auth/password", { current_password: current, new_password: next }),
signOutElsewhere: () => api.post<SignedOutElsewhere>("/api/auth/sign-out-elsewhere"),
},
devices: {
+3 -3
View File
@@ -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"
>
<Icon name="device" />
</RouterLink>
@@ -458,7 +458,7 @@ async function signOut() {
>
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
<RouterLink to="/account" class="nav-link" exact-active-class="nav-link-active">
<Icon name="device" /> Linked devices
<Icon name="device" /> Account
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
+3 -2
View File
@@ -48,7 +48,8 @@ const router = createRouter({
meta: { title: "Sync", requiresAuth: true, requiresDesktop: true },
},
{
// Per-user account: linked devices (native-client sync tokens). Any user.
// Per-user account: linked devices (native-client sync tokens), the password,
// and signing out everywhere else. Any user.
//
// The mirror of `requiresDesktop` above: this one needs a SERVER. The desktop
// is itself one of the devices this page lists, so offline the list is always
@@ -58,7 +59,7 @@ const router = createRouter({
path: "/account",
name: "account",
component: () => import("../views/AccountView.vue"),
meta: { title: "Linked devices", requiresAuth: true, requiresServer: true },
meta: { title: "Account", requiresAuth: true, requiresServer: true },
},
{
path: "/login",
+11 -1
View File
@@ -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<number> {
return (await repo.auth.changePassword(current, next)).devices_unlinked;
}
async function signOutElsewhere(): Promise<number> {
return (await repo.auth.signOutElsewhere()).devices_unlinked;
}
return { user, loaded, fetchMe, login, register, logout, changePassword, signOutElsewhere };
});
+90 -3
View File
@@ -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(() => {
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<PageHeader title="Linked devices" />
<PageHeader title="Account" />
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked devices</h2>
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
Link the Inkwell desktop or mobile app to sync your notes. Create a device token here, then
paste it into the app when it asks to connect. You can revoke a device at any time.
@@ -164,5 +212,44 @@ onMounted(() => {
</button>
</li>
</ul>
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Password</h2>
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">Changing it signs you out everywhere else.</p>
<form class="flex max-w-sm flex-col gap-3" @submit.prevent="changePassword">
<BaseInput
id="current-password"
v-model="currentPassword"
type="password"
label="Current password"
autocomplete="current-password"
required
/>
<BaseInput
id="new-password"
v-model="newPassword"
type="password"
label="New password"
autocomplete="new-password"
placeholder="At least 8 characters"
:error="passwordError"
required
/>
<div>
<BaseButton type="submit" :loading="changing">Change password</BaseButton>
</div>
</form>
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Sessions</h2>
<div
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Sign out everywhere else</p>
<p class="text-xs text-neutral-400">Every other browser, and every linked device</p>
</div>
<BaseButton variant="ghost" class="shrink-0" :loading="signingOut" @click="signOutElsewhere">
Sign out
</BaseButton>
</div>
</div>
</template>
+79 -4
View File
@@ -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 ---
+86
View File
@@ -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