Files
inkwell/src/inkwell/password_resets.py
T
bvandeusenandClaude Opus 5.5 2ce68f578a DRY pass #2, batch 8: stale comments (#5372)
useNoteList no longer names a Search view; the desktop adapter's M10.7 plan
gave way to sync under the local core; local.ts's sharing comment sat above
settings; AccountList and password_resets still said there was no mail path;
NoteEditor kept an orphan checklist-flag comment, a textarea comment from
before blocks, and the link-preview comment above the file picker; the
serialize docstring's growth plan is now what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:03:50 -04:00

109 lines
4.4 KiB
Python

"""Password resets: an admin makes a one-hour link for an account (#5173).
Before this, a forgotten password needed 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.
A person can also ask for one themselves when the server can send email (#5266):
`mail_reset_link` makes the same link and mails it to the account's address.
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 logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .db import session_scope
from .mailer import mail_settings, send
from .models.password_reset import PasswordReset
from .models.user import User
from .security import generate_token, hash_token
from .settings import get_setting
logger = logging.getLogger(__name__)
# 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 | None) -> tuple[str, datetime]:
"""Make a reset link for the account, returning the token and its expiry. `by` is
the admin who made it, or None when the person asked by email.
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)
)
async def mail_reset_link(email: str) -> None:
"""Email a reset link to the account with this address, if there is one.
Run off the request (`mailer.send_later`), so the person asking gets the same
answer at the same speed whether or not the address has an account. Logs and
swallows every failure: there is no one left to tell.
"""
try:
async with session_scope() as db:
cfg = await mail_settings(db)
user = await db.scalar(select(User).where(User.email == email))
if cfg is None or user is None:
return
token, _ = await issue(db, user.id, None)
await db.commit()
site = await get_setting(db, "site_name")
to = user.email
link = f"{cfg.public_url}/reset-password?token={token}"
await send(
cfg,
to,
f"Reset your {site} password",
f"Someone asked to reset the password for {to} on {site}.\n\n"
f"If it was you, open this link within the hour to choose a new one:\n\n{link}\n\n"
"Setting it signs you out everywhere else. If it wasn't you, ignore this "
"email and your password stays as it is.\n",
)
logger.info("password reset email sent to=%s", to)
except Exception:
logger.exception("password reset email failed for=%s", email)