From 3dd0b44cb902bc500dbfa3119f392c10b26ee0be Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 14:35:58 -0400 Subject: [PATCH] password reset: an admin makes a one-hour link, and using it signs the account out everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- alembic/versions/0033_password_resets.py | 50 +++++++++++ docs/public-hosting.md | 7 +- frontend/src/components/AccountList.vue | 98 ++++++++++++++++++++ frontend/src/components/InviteList.vue | 51 ++--------- frontend/src/components/OneTimeLink.vue | 48 ++++++++++ frontend/src/router/index.ts | 9 ++ frontend/src/router/links.ts | 9 ++ frontend/src/views/ResetPasswordView.vue | 108 +++++++++++++++++++++++ frontend/src/views/SettingsView.vue | 6 +- src/inkwell/accounts_api.py | 61 +++++++++++++ src/inkwell/app.py | 2 + src/inkwell/auth.py | 95 +++++++++++++++++--- src/inkwell/models/all.py | 1 + src/inkwell/models/password_reset.py | 33 +++++++ src/inkwell/models/user.py | 5 +- src/inkwell/password_resets.py | 66 ++++++++++++++ tests/test_integration.py | 108 ++++++++++++++++++++++- 17 files changed, 696 insertions(+), 61 deletions(-) create mode 100644 alembic/versions/0033_password_resets.py create mode 100644 frontend/src/components/AccountList.vue create mode 100644 frontend/src/components/OneTimeLink.vue create mode 100644 frontend/src/router/links.ts create mode 100644 frontend/src/views/ResetPasswordView.vue create mode 100644 src/inkwell/accounts_api.py create mode 100644 src/inkwell/models/password_reset.py create mode 100644 src/inkwell/password_resets.py diff --git a/alembic/versions/0033_password_resets.py b/alembic/versions/0033_password_resets.py new file mode 100644 index 0000000..5b496b9 --- /dev/null +++ b/alembic/versions/0033_password_resets.py @@ -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") diff --git a/docs/public-hosting.md b/docs/public-hosting.md index b4607cc..5ec2f4d 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -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. -- **No email verification and no password reset.** `email_verified` exists on the user - row and nothing sets it. A forgotten password needs a hand on the database. +- **No email at all.** `email_verified` exists on the user row and nothing sets it. + 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 per-user storage quota.** Any account can upload attachments until the volume is full. `max_attachment_mb` caps a single file, not a total. diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue new file mode 100644 index 0000000..ac2388d --- /dev/null +++ b/frontend/src/components/AccountList.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/components/InviteList.vue b/frontend/src/components/InviteList.vue index fb06879..8c80a57 100644 --- a/frontend/src/components/InviteList.vue +++ b/frontend/src/components/InviteList.vue @@ -1,12 +1,12 @@ + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9b5ce82..541aca0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -72,6 +72,15 @@ const router = createRouter({ component: () => import("../views/RegisterView.vue"), 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" }, + }, ], }); diff --git a/frontend/src/router/links.ts b/frontend/src/router/links.ts new file mode 100644 index 0000000..f419291 --- /dev/null +++ b/frontend/src/router/links.ts @@ -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; +} diff --git a/frontend/src/views/ResetPasswordView.vue b/frontend/src/views/ResetPasswordView.vue new file mode 100644 index 0000000..1fe064e --- /dev/null +++ b/frontend/src/views/ResetPasswordView.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 89bd590..54905ae 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -4,6 +4,7 @@ import { api } from "../api/client"; import { useConfigStore } from "../stores/config"; import BaseButton from "../components/BaseButton.vue"; import InviteList from "../components/InviteList.vue"; +import AccountList from "../components/AccountList.vue"; import { errorMessage } from "../api/errors"; interface SettingItem { @@ -172,8 +173,9 @@ onMounted(load); - + + diff --git a/src/inkwell/accounts_api.py b/src/inkwell/accounts_api.py new file mode 100644 index 0000000..878423b --- /dev/null +++ b/src/inkwell/accounts_api.py @@ -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("//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 diff --git a/src/inkwell/app.py b/src/inkwell/app.py index 5a41883..148de07 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -11,6 +11,7 @@ from datetime import timedelta from quart import Quart, jsonify, send_from_directory from quart.sessions import SecureCookieSessionInterface +from .accounts_api import bp as accounts_bp from .auth import bp as auth_bp from .client_dist import advertisement as client_advertisement, bp as client_bp from .config import Config @@ -93,6 +94,7 @@ def create_app() -> Quart: app.register_blueprint(labels_bp) app.register_blueprint(settings_bp) app.register_blueprint(invites_bp) + app.register_blueprint(accounts_bp) app.register_blueprint(sync_bp) app.register_blueprint(saved_filters_bp) app.register_blueprint(client_bp) diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index eba5255..1a0ad05 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -6,11 +6,12 @@ import uuid from datetime import datetime, timezone from quart import Blueprint, g, jsonify, request, session -from sqlalchemy import func, select +from sqlalchemy import delete, func, select from .common import iso from .db import session_scope 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.user import User from .proxy import client_address @@ -35,6 +36,11 @@ bp = Blueprint("auth", __name__, url_prefix="/api/auth") logger = logging.getLogger(__name__) 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 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: raw = session.get(SESSION_KEY) if not raw: @@ -56,7 +83,7 @@ def _session_user_id() -> uuid.UUID | None: try: return uuid.UUID(raw) except (ValueError, TypeError): - session.pop(SESSION_KEY, None) + _sign_out() return None @@ -86,12 +113,20 @@ async def _user_id_from_bearer() -> uuid.UUID | None: def login_required(fn): """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 - stays DB-free (fast); only bearer auth does a token lookup.""" + bearer token (native clients). Sets g.user_id for the view. + + 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) async def wrapper(*args, **kwargs): 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: uid = await _user_id_from_bearer() if uid is None: @@ -113,8 +148,7 @@ def require_admin(fn): return jsonify({"error": "authentication required"}), 401 async with session_scope() as db: user = await db.get(User, uid) - if user is None: - session.pop(SESSION_KEY, None) + if not _session_is_current(user.session_epoch if user else None): return jsonify({"error": "authentication required"}), 401 if not user.is_admin: return jsonify({"error": "admin access required"}), 403 @@ -241,8 +275,7 @@ async def register(): await record_redeemer(db, invite_id, user.id) await db.commit() await db.refresh(user) - session[SESSION_KEY] = str(user.id) - session.permanent = True + _sign_in(user) logger.info( "account created email=%s admin=%s invite=%s from=%s", 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()) return jsonify({"error": "invalid email or password"}), 401 _sign_in_succeeded(email) - session[SESSION_KEY] = str(user.id) - session.permanent = True + _sign_in(user) logger.info("sign-in ok email=%s from=%s", email, client_address()) return jsonify(_serialize_user(user)) @bp.post("/logout") async def logout(): - session.pop(SESSION_KEY, None) + _sign_out() return jsonify({"ok": True}) @@ -293,11 +325,50 @@ async def me(): async with session_scope() as db: user = await db.get(User, g.user_id) if user is None: - session.pop(SESSION_KEY, None) + _sign_out() return jsonify({"error": "authentication required"}), 401 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 --- diff --git a/src/inkwell/models/all.py b/src/inkwell/models/all.py index fbac014..a6f05ba 100644 --- a/src/inkwell/models/all.py +++ b/src/inkwell/models/all.py @@ -12,6 +12,7 @@ from . import ( # noqa: F401 note_attachment, note_link_preview, note_revision, + password_reset, saved_filter, settings, share, diff --git a/src/inkwell/models/password_reset.py b/src/inkwell/models/password_reset.py new file mode 100644 index 0000000..8928d9d --- /dev/null +++ b/src/inkwell/models/password_reset.py @@ -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) diff --git a/src/inkwell/models/user.py b/src/inkwell/models/user.py index aad2fc9..ea3427e 100644 --- a/src/inkwell/models/user.py +++ b/src/inkwell/models/user.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid 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.orm import Mapped, mapped_column @@ -22,6 +22,9 @@ class User(Base): # Nullable: leaves room for external-identity-only accounts later (rule 26). password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True) 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) 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()) diff --git a/src/inkwell/password_resets.py b/src/inkwell/password_resets.py new file mode 100644 index 0000000..8ca3f58 --- /dev/null +++ b/src/inkwell/password_resets.py @@ -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) + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index ef4e341..6609515 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -29,6 +29,7 @@ from inkwell.app import create_app from inkwell.config import Config from inkwell.db import dispose_engine, session_scope from inkwell.models.invite import Invite +from inkwell.models.password_reset import PasswordReset from inkwell.models.label import NoteLabel from inkwell.models.note import Note 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. # 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 @@ -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)) 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