AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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