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>
109 lines
4.4 KiB
Python
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)
|