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

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:
2026-10-07 14:35:58 -04:00
co-authored by Claude Opus 5.5
parent 28fa8badcb
commit 3dd0b44cb9
17 changed files with 696 additions and 61 deletions
+50
View File
@@ -0,0 +1,50 @@
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
Revision ID: 0033
Revises: 0032
Create Date: 2026-10-07
A forgotten password used to need a hand on the database (#2939 §2), and the app has
no mail path to send a reset by. An admin makes a reset link for the account and
hands it over (#5173). Only the token's SHA-256 hash is stored.
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
are signed cookies held by the browser, so the server can't delete them; each one
carries the epoch it was signed in under, and a reset moves the account's epoch on.
It starts at 0, the value a cookie from before this migration is read as, so nobody
is signed out by the upgrade itself.
## Downgrade
Drops the table and the column. Outstanding reset links stop working; sessions keep
working, since nothing checks an epoch any more.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
op.create_table(
"password_resets",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
op.drop_table("password_resets")
op.drop_column("users", "session_epoch")
+5 -2
View File
@@ -112,8 +112,11 @@ docker run --rm -v inkwell-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz
Know these before you decide who gets an account. Know these before you decide who gets an account.
- **No email verification and no password reset.** `email_verified` exists on the user - **No email at all.** `email_verified` exists on the user row and nothing sets it.
row and nothing sets it. A forgotten password needs a hand on the database. A forgotten password is reset by an admin: Settings → People → Reset password makes
a link that works once, within an hour, and the admin hands it over. Using it signs
the account out everywhere and unlinks its apps. An admin who forgets their own
password and has no other admin still needs a hand on the database.
- **No second factor.** A password is the whole of it. - **No second factor.** A password is the whole of it.
- **No per-user storage quota.** Any account can upload attachments until the volume - **No per-user storage quota.** Any account can upload attachments until the volume
is full. `max_attachment_mb` caps a single file, not a total. is full. `max_attachment_mb` caps a single file, not a total.
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useSessionStore } from "../stores/session";
import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue";
// Admin: the accounts on this instance, and a password reset link for any of them
// (#5173). There is no mail path, so the admin hands the link over.
interface Account {
id: string;
email: string;
display_name: string;
is_admin: boolean;
}
const session = useSessionStore();
const ui = useUiStore();
const accounts = ref<Account[]>([]);
const loading = ref(true);
const error = ref("");
const busy = ref("");
// The reset link just made, and whose it is. Never retrievable again once dismissed.
const fresh = ref<{ link: string; email: string } | null>(null);
async function load() {
error.value = "";
try {
accounts.value = (await api.get<{ accounts: Account[] }>("/api/accounts")).accounts;
} catch (e) {
error.value = errorMessage(e, "Couldn't load accounts.");
} finally {
loading.value = false;
}
}
async function resetLink(account: Account) {
const self = account.id === session.user?.id;
const warning = self
? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included."
: `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`;
if (!window.confirm(warning)) return;
busy.value = account.id;
try {
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't make a reset link."));
} finally {
busy.value = "";
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">People</h2>
<OneTimeLink
v-if="fresh"
:link="fresh.link"
:note="`Password reset link for ${fresh.email}. It works once, within an hour.`"
@done="fresh = null"
/>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="a in accounts"
:key="a.id"
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="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ a.display_name }}</p>
<p class="truncate text-xs text-neutral-400">{{ a.email }}{{ a.is_admin ? " · Admin" : "" }}</p>
</div>
<button
type="button"
class="shrink-0 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="busy === a.id"
@click="resetLink(a)"
>
Reset password
</button>
</li>
</ul>
</section>
</template>
+8 -43
View File
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client"; import { api } from "../api/client";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue"; import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue"; import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue"; import OneTimeLink from "./OneTimeLink.vue";
// Admin: invite one person to register while registration stays closed (#5172). // Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once, // The server keeps only a hash of each invite's token, so the link is shown once,
@@ -29,7 +29,6 @@ const LIFETIMES = [
{ days: 30, label: "30 days" }, { days: 30, label: "30 days" },
]; ];
const router = useRouter();
const ui = useUiStore(); const ui = useUiStore();
const invites = ref<Invite[]>([]); const invites = ref<Invite[]>([]);
@@ -52,12 +51,6 @@ async function load() {
} }
} }
/** The register page's address with the token on it, as people reach this server. */
function linkFor(token: string): string {
const path = router.resolve({ name: "register", query: { invite: token } }).href;
return new URL(path, window.location.origin).href;
}
async function create() { async function create() {
creating.value = true; creating.value = true;
error.value = ""; error.value = "";
@@ -67,7 +60,7 @@ async function create() {
email: email.value.trim() || null, email: email.value.trim() || null,
days: days.value, days: days.value,
}); });
freshLink.value = linkFor(res.token); freshLink.value = appLink({ name: "register", query: { invite: res.token } });
invites.value = [res.invite, ...invites.value]; invites.value = [res.invite, ...invites.value];
email.value = ""; email.value = "";
} catch (e) { } catch (e) {
@@ -77,15 +70,6 @@ async function create() {
} }
} }
async function copyLink() {
try {
await navigator.clipboard.writeText(freshLink.value);
ui.showToast("Invite link copied.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(invite: Invite) { async function revoke(invite: Invite) {
if (!window.confirm(`Revoke the invite for ${who(invite)}? Its link will stop working.`)) return; if (!window.confirm(`Revoke the invite for ${who(invite)}? Its link will stop working.`)) return;
try { try {
@@ -127,31 +111,12 @@ onMounted(() => {
<section class="flex flex-col gap-5"> <section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Invites</h2> <h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Invites</h2>
<!-- One-time link reveal --> <OneTimeLink
<div
v-if="freshLink" v-if="freshLink"
class="rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10" :link="freshLink"
> note="Send this to the person you're inviting."
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100"> @done="freshLink = ''"
Copy this link now — it won't be shown again. />
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshLink }}</code
>
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copyLink">
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshLink = ''"
>
Done
</button>
</div>
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create"> <form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
<BaseInput <BaseInput
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue";
// A link carrying a token the server keeps only a hash of, so this is the one time
// it can be shown: invites (#5172) and password reset links (#5173).
const props = defineProps<{
link: string;
/** What the link is, shown above it. */
note: string;
}>();
const emit = defineEmits<{ done: [] }>();
const ui = useUiStore();
async function copy() {
try {
await navigator.clipboard.writeText(props.link);
ui.showToast("Link copied.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
</script>
<template>
<div class="rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ note }}</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">Copy it now — it won't be shown again.</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ link }}</code
>
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copy">
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="emit('done')"
>
Done
</button>
</div>
</template>
+9
View File
@@ -72,6 +72,15 @@ const router = createRouter({
component: () => import("../views/RegisterView.vue"), component: () => import("../views/RegisterView.vue"),
meta: { title: "Create account", guestOnly: true }, meta: { title: "Create account", guestOnly: true },
}, },
{
// Where an admin-made password reset link lands (#5173). Not guest-only: the
// link signs in whoever uses it as the account it was made for, whoever was
// signed in on this browser before.
path: "/reset-password",
name: "reset-password",
component: () => import("../views/ResetPasswordView.vue"),
meta: { title: "Reset password" },
},
], ],
}); });
+9
View File
@@ -0,0 +1,9 @@
import type { RouteLocationRaw } from "vue-router";
import router from "./index";
/** A full link to a page of this app, as people reach this server: what an admin
* copies into a message (an invite, a password reset link). Built here because only
* the browser knows the address people actually use. */
export function appLink(to: RouteLocationRaw): string {
return new URL(router.resolve(to).href, window.location.origin).href;
}
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import BaseInput from "../components/BaseInput.vue";
import BaseButton from "../components/BaseButton.vue";
// Where a password reset link an admin made lands (#5173). Setting the password signs
// this browser in and every other session and linked app out.
const session = useSessionStore();
const config = useConfigStore();
const router = useRouter();
const route = useRoute();
const token = computed(() => (typeof route.query.token === "string" ? route.query.token : ""));
const password = ref("");
const confirm = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
error.value = "";
if (password.value.length < 8) {
error.value = "Password must be at least 8 characters.";
return;
}
if (password.value !== confirm.value) {
error.value = "The two passwords don't match.";
return;
}
loading.value = true;
try {
await api.post("/api/auth/reset-password", { token: token.value, password: password.value });
await session.fetchMe();
await router.replace("/");
} catch (e) {
error.value = errorMessage(e, "Couldn't set your password.");
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="flex min-h-full items-center justify-center px-4 py-12">
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<img
src="/icon.svg"
:alt="config.siteName"
class="mx-auto mb-3 h-12 w-12 rounded-xl"
width="48"
height="48"
/>
<h1 class="text-2xl font-bold tracking-tight">Choose a new password</h1>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
You'll be signed out everywhere else, and your apps will ask you to sign in again.
</p>
</div>
<p
v-if="!token"
role="alert"
class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/50 dark:text-red-300"
>
This link is incomplete. Ask your admin for a new one.
</p>
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="submit">
<BaseInput
id="password"
v-model="password"
label="New password"
type="password"
autocomplete="new-password"
placeholder="At least 8 characters"
required
/>
<BaseInput
id="confirm"
v-model="confirm"
label="Again"
type="password"
autocomplete="new-password"
required
/>
<p
v-if="error"
role="alert"
class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/50 dark:text-red-300"
>
{{ error }}
</p>
<BaseButton type="submit" :loading="loading">Set password</BaseButton>
</form>
<p class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
<RouterLink to="/login" class="font-semibold text-brand-700 hover:underline dark:text-brand"
>Back to sign in</RouterLink
>
</p>
</div>
</main>
</template>
+4 -2
View File
@@ -4,6 +4,7 @@ import { api } from "../api/client";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
import InviteList from "../components/InviteList.vue"; import InviteList from "../components/InviteList.vue";
import AccountList from "../components/AccountList.vue";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
interface SettingItem { interface SettingItem {
@@ -172,8 +173,9 @@ onMounted(load);
</div> </div>
</form> </form>
<!-- Outside the settings form: each invite action saves on its own, and the <!-- Outside the settings form: each invite and account action saves on its own,
form's Save button has nothing to do with them. --> and the form's Save button has nothing to do with them. -->
<InviteList v-if="items.length" class="mt-10" /> <InviteList v-if="items.length" class="mt-10" />
<AccountList v-if="items.length" class="mt-10" />
</div> </div>
</template> </template>
+61
View File
@@ -0,0 +1,61 @@
"""Admin routes over the instance's accounts: list them, and make a password reset
link for one (#5173).
What a reset link is, and how it is used, is in `password_resets.py`.
"""
from __future__ import annotations
import logging
import uuid
from quart import Blueprint, g, jsonify
from sqlalchemy import select
from .auth import require_admin
from .common import iso
from .db import session_scope
from .models.user import User
from .password_resets import issue
from .proxy import client_address
bp = Blueprint("accounts", __name__, url_prefix="/api/accounts")
# Credential events go to the app log, as in `auth` (there is no audit table yet).
logger = logging.getLogger(__name__)
def _serialize_account(user: User) -> dict:
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"is_admin": user.is_admin,
"created_at": iso(user.created_at),
}
@bp.get("")
@require_admin
async def list_accounts():
async with session_scope() as db:
users = (await db.scalars(select(User).order_by(User.created_at))).all()
return jsonify({"accounts": [_serialize_account(u) for u in users]})
@bp.post("/<account_id>/reset-link")
@require_admin
async def create_reset_link(account_id: str):
try:
uid = uuid.UUID(account_id)
except ValueError:
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
user = await db.get(User, uid)
if user is None:
return jsonify({"error": "not found"}), 404
token, expires_at = await issue(db, uid, g.user_id)
await db.commit()
email = user.email
logger.info("password reset link made for=%s by=%s from=%s", email, g.user_id, client_address())
# The token goes back exactly once; the client builds the link from it, as for invites.
return jsonify({"token": token, "expires_at": iso(expires_at)}), 201
+2
View File
@@ -11,6 +11,7 @@ from datetime import timedelta
from quart import Quart, jsonify, send_from_directory from quart import Quart, jsonify, send_from_directory
from quart.sessions import SecureCookieSessionInterface from quart.sessions import SecureCookieSessionInterface
from .accounts_api import bp as accounts_bp
from .auth import bp as auth_bp from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config from .config import Config
@@ -93,6 +94,7 @@ def create_app() -> Quart:
app.register_blueprint(labels_bp) app.register_blueprint(labels_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(invites_bp) app.register_blueprint(invites_bp)
app.register_blueprint(accounts_bp)
app.register_blueprint(sync_bp) app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp) app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp) app.register_blueprint(client_bp)
+83 -12
View File
@@ -6,11 +6,12 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request, session from quart import Blueprint, g, jsonify, request, session
from sqlalchemy import func, select from sqlalchemy import delete, func, select
from .common import iso from .common import iso
from .db import session_scope from .db import session_scope
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
from .password_resets import INVALID as INVALID_RESET, claim as claim_reset
from .models.device_token import DeviceToken from .models.device_token import DeviceToken
from .models.user import User from .models.user import User
from .proxy import client_address from .proxy import client_address
@@ -35,6 +36,11 @@ bp = Blueprint("auth", __name__, url_prefix="/api/auth")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SESSION_KEY = "user_id" SESSION_KEY = "user_id"
# The account's `session_epoch` when this session signed in. A password reset moves
# the account's epoch on, which is how it ends sessions it can't reach: they are
# signed cookies in other people's browsers. A cookie from before epochs existed has
# no key and reads as 0, the epoch every account started at.
EPOCH_KEY = "epoch"
MIN_PASSWORD_LEN = 8 MIN_PASSWORD_LEN = 8
DEVICE_NAME_CAP = 100 DEVICE_NAME_CAP = 100
@@ -49,6 +55,27 @@ def _serialize_user(user: User) -> dict:
} }
def _sign_in(user: User) -> None:
session[SESSION_KEY] = str(user.id)
session[EPOCH_KEY] = user.session_epoch
session.permanent = True
def _sign_out() -> None:
session.pop(SESSION_KEY, None)
session.pop(EPOCH_KEY, None)
def _session_is_current(epoch: int | None) -> bool:
"""Whether the signed-in session still stands, given its account's epoch (None
when the account is gone). False once a reset has signed the account out
everywhere since, and the session is cleared then."""
if epoch is not None and session.get(EPOCH_KEY, 0) == epoch:
return True
_sign_out()
return False
def _session_user_id() -> uuid.UUID | None: def _session_user_id() -> uuid.UUID | None:
raw = session.get(SESSION_KEY) raw = session.get(SESSION_KEY)
if not raw: if not raw:
@@ -56,7 +83,7 @@ def _session_user_id() -> uuid.UUID | None:
try: try:
return uuid.UUID(raw) return uuid.UUID(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
session.pop(SESSION_KEY, None) _sign_out()
return None return None
@@ -86,12 +113,20 @@ async def _user_id_from_bearer() -> uuid.UUID | None:
def login_required(fn): def login_required(fn):
"""Guard: 401 unless authenticated. Accepts a web session cookie OR a device """Guard: 401 unless authenticated. Accepts a web session cookie OR a device
bearer token (native clients). Sets g.user_id for the view. The session path bearer token (native clients). Sets g.user_id for the view.
stays DB-free (fast); only bearer auth does a token lookup."""
The session path reads one column by primary key. It used to read nothing, which
left no way to end a session short of its expiry: not for a reset password, and
not for a deleted account (#5173)."""
@functools.wraps(fn) @functools.wraps(fn)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
uid = _session_user_id() uid = _session_user_id()
if uid is not None:
async with session_scope() as db:
epoch = await db.scalar(select(User.session_epoch).where(User.id == uid))
if not _session_is_current(epoch):
uid = None
if uid is None: if uid is None:
uid = await _user_id_from_bearer() uid = await _user_id_from_bearer()
if uid is None: if uid is None:
@@ -113,8 +148,7 @@ def require_admin(fn):
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
async with session_scope() as db: async with session_scope() as db:
user = await db.get(User, uid) user = await db.get(User, uid)
if user is None: if not _session_is_current(user.session_epoch if user else None):
session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
if not user.is_admin: if not user.is_admin:
return jsonify({"error": "admin access required"}), 403 return jsonify({"error": "admin access required"}), 403
@@ -241,8 +275,7 @@ async def register():
await record_redeemer(db, invite_id, user.id) await record_redeemer(db, invite_id, user.id)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
session[SESSION_KEY] = str(user.id) _sign_in(user)
session.permanent = True
logger.info( logger.info(
"account created email=%s admin=%s invite=%s from=%s", "account created email=%s admin=%s invite=%s from=%s",
email, is_first, invite_id, client_address(), email, is_first, invite_id, client_address(),
@@ -275,15 +308,14 @@ async def login():
logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address()) logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address())
return jsonify({"error": "invalid email or password"}), 401 return jsonify({"error": "invalid email or password"}), 401
_sign_in_succeeded(email) _sign_in_succeeded(email)
session[SESSION_KEY] = str(user.id) _sign_in(user)
session.permanent = True
logger.info("sign-in ok email=%s from=%s", email, client_address()) logger.info("sign-in ok email=%s from=%s", email, client_address())
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
@bp.post("/logout") @bp.post("/logout")
async def logout(): async def logout():
session.pop(SESSION_KEY, None) _sign_out()
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -293,11 +325,50 @@ async def me():
async with session_scope() as db: async with session_scope() as db:
user = await db.get(User, g.user_id) user = await db.get(User, g.user_id)
if user is None: if user is None:
session.pop(SESSION_KEY, None) _sign_out()
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
@bp.post("/reset-password")
async def reset_password():
"""Set a new password with a reset link an admin made (#5173), and sign the
account out everywhere else: every other session, and every linked device.
The browser that used the link is signed in under the new password, since holding
the link and choosing the password is as much as a sign-in proves.
"""
data = await request.get_json(silent=True) or {}
token = (data.get("token") or "").strip()
password = data.get("password") or ""
if len(password) < MIN_PASSWORD_LEN:
return jsonify({"error": f"password must be at least {MIN_PASSWORD_LEN} characters"}), 400
# The sign-in budget for this address. A token can't be guessed, but each attempt
# is still a credential attempt, and the log should show a run of them.
wait = _sign_in_block("")
if wait is not None:
return _throttled(wait)
async with session_scope() as db:
user_id = await claim_reset(db, token) if token else None
if user_id is None:
_sign_in_failed("")
logger.warning("password reset refused (bad link) from=%s", client_address())
return jsonify({"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)
_sign_in(user)
logger.info(
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
)
return jsonify(_serialize_user(user))
# --- Device (bearer) tokens for native clients — M8 sync hub --- # --- Device (bearer) tokens for native clients — M8 sync hub ---
+1
View File
@@ -12,6 +12,7 @@ from . import ( # noqa: F401
note_attachment, note_attachment,
note_link_preview, note_link_preview,
note_revision, note_revision,
password_reset,
saved_filter, saved_filter,
settings, settings,
share, share,
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class PasswordReset(Base):
"""A one-time link an admin makes so a person who forgot their password can set
a new one (#5173). There is no mail path; the admin hands the link over.
Only the token's SHA-256 hash is stored, as for invites and device tokens. Making
a new link for an account deletes its unused ones, so only the newest works.
"""
__tablename__ = "password_resets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
token_hash: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+4 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Text, func from sqlalchemy import Boolean, DateTime, Integer, Text, func
from sqlalchemy.dialects.postgresql import CITEXT, UUID from sqlalchemy.dialects.postgresql import CITEXT, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -22,6 +22,9 @@ class User(Base):
# Nullable: leaves room for external-identity-only accounts later (rule 26). # Nullable: leaves room for external-identity-only accounts later (rule 26).
password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True) password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
display_name: Mapped[str] = mapped_column(Text(), nullable=False) display_name: Mapped[str] = mapped_column(Text(), nullable=False)
# Moved on by a password reset to sign the account out everywhere: every session
# carries the epoch it signed in under, and one from an older epoch is refused.
session_epoch: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
avatar_path: Mapped[str | None] = mapped_column(Text(), nullable=True) avatar_path: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+66
View File
@@ -0,0 +1,66 @@
"""Password resets: an admin makes a one-hour link for an account (#5173).
The app has no mail path, so a forgotten password used to need a hand on the database
(#2939 §2). This reuses what invites established: a random token, only its hash
kept, the link shown once to the admin who hands it over.
Using the link sets a new password and signs the account out everywhere. Its web
sessions end because the account's `session_epoch` moves on (see `auth`), and its
device tokens are deleted, so each linked app has to sign in again.
This module is the reset itself; `auth.reset_password` redeems it and the admin route
is in `accounts_api.py`, the same split as invites and for the same reason.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from .models.password_reset import PasswordReset
from .security import generate_token, hash_token
# Long enough to read a message and act on it; short enough that a link left in a
# chat history is dead by the time anyone else scrolls past it.
LIFETIME = timedelta(hours=1)
# The one answer to every failed redemption, as for invites.
INVALID = "invalid or expired reset link"
async def issue(db: AsyncSession, user_id: uuid.UUID, by: uuid.UUID) -> tuple[str, datetime]:
"""Make a reset link for the account, returning the token and its expiry.
Its earlier unused links are deleted first, so only the newest one works: an admin
who makes a second link because the first went astray has closed the first.
Not committed here.
"""
await db.execute(
delete(PasswordReset).where(PasswordReset.user_id == user_id, PasswordReset.used_at.is_(None))
)
token = generate_token()
expires_at = datetime.now(timezone.utc) + LIFETIME
db.add(PasswordReset(token_hash=hash_token(token), user_id=user_id, created_by=by, expires_at=expires_at))
return token, expires_at
async def claim(db: AsyncSession, token: str) -> uuid.UUID | None:
"""Use up the link, returning the account it resets, or None if it can't be used.
One conditional UPDATE, so the same link can't be used twice even by two requests
at once. Not committed here: the caller commits with the new password, so a reset
that fails leaves the link usable.
"""
now = datetime.now(timezone.utc)
return await db.scalar(
update(PasswordReset)
.where(
PasswordReset.token_hash == hash_token(token),
PasswordReset.used_at.is_(None),
PasswordReset.expires_at > now,
)
.values(used_at=now)
.returning(PasswordReset.user_id)
)
+107 -1
View File
@@ -29,6 +29,7 @@ from inkwell.app import create_app
from inkwell.config import Config from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope from inkwell.db import dispose_engine, session_scope
from inkwell.models.invite import Invite from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset
from inkwell.models.label import NoteLabel from inkwell.models.label import NoteLabel
from inkwell.models.note import Note from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment 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. # 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. # 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 @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)) count = await fresh.scalar(select(func.count()).select_from(User))
assert count == 2, "the admin and exactly one of the two" 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