Source code for enlace_auth.auth.reset_tokens

"""Password-reset tokens — minting, verification, and the link they live in.

Single source of truth for the reset-link format, shared by the three callers
that must agree on it byte-for-byte: the self-service
``/auth/password-reset/*`` endpoints, the admin
``POST /_admin/api/users/{email}/reset-link`` endpoint, and the
``enlace-auth reset-link`` CLI.

The token is a *signed* (never encrypted) string carrying three fields::

    <password fingerprint> | <absolute expiry, epoch seconds> | <email>

Two properties fall out of that shape, and neither needs a server-side token
store:

- **Single use.** The fingerprint is a hash of the account's *current* password
  hash. Consuming a link changes the password, which changes the hash, which
  invalidates that token — and every other link outstanding for the account.
- **Per-link lifetime.** The expiry lives *inside* the signed payload rather
  than being applied at verification time, so one verifier serves both the
  short-lived link sent by email and the longer-lived link an admin hands over
  out-of-band (chat, a phone call) when SMTP isn't configured.

The signature covers the expiry, so a longer lifetime cannot be forged. A
second, independent ceiling (:data:`MAX_TTL_SECONDS`) is still applied at the
signature layer, so no token can outlive it even if the embedded expiry is
somehow mis-parsed.
"""

from __future__ import annotations

import hashlib
import time
from collections.abc import Mapping
from typing import Any, Optional

from enlace_auth.auth.cookies import sign_cookie, verify_cookie

# Salt namespace for reset tokens — distinct from "session" / "csrf" /
# "shared:*" so a token minted for one purpose can never verify for another.
RESET_SALT = "pwreset"

# Field separator. Safe because the fingerprint is hex, the expiry is digits,
# and an email address cannot contain "|".
_SEP = "|"

#: Lifetime of a link delivered by email — short, because the inbox is a
#: hostile place to leave a credential lying around.
DEFAULT_EMAIL_TTL = 1800  # 30 minutes

#: Lifetime of a link an admin mints and delivers by hand. Longer because the
#: round trip is human: the admin sends it, the recipient reads it later.
DEFAULT_HANDOFF_TTL = 72 * 3600  # 3 days

#: Absolute ceiling on any reset link, enforced at the signature layer as a
#: second bound independent of the expiry inside the payload.
MAX_TTL_SECONDS = 30 * 24 * 3600  # 30 days


[docs] def password_fingerprint(record: Mapping[str, Any]) -> str: """Return a short, stable fingerprint of a user's current password hash. Embedded in reset tokens so a token stops working the moment the password changes. That is what makes every link naturally single-use (using it changes the hash) and what invalidates outstanding links after any other password change. """ raw = str(record.get("password_hash")) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
[docs] def mint_reset_token( *, record: Mapping[str, Any], email: str, signing_key: str, ttl_seconds: int = DEFAULT_EMAIL_TTL, ) -> str: """Return a signed reset token for ``email``, valid for ``ttl_seconds``. Args: record: the user's stored record (read for its ``password_hash``). email: the account the token authorizes a password change for. signing_key: platform HMAC key. ttl_seconds: how long the link stays usable. Clamped to :data:`MAX_TTL_SECONDS`. """ ttl = max(1, min(int(ttl_seconds), MAX_TTL_SECONDS)) expires_at = int(time.time()) + ttl payload = _SEP.join((password_fingerprint(record), str(expires_at), email)) return sign_cookie(payload, signing_key, salt=RESET_SALT)
[docs] def verify_reset_token( token: str, *, signing_key: str, user_store ) -> tuple[Optional[str], Optional[dict]]: """Return ``(email, record)`` for a valid token, else ``(None, None)``. Four independent checks must all pass: the signature, the signature-layer age ceiling, the expiry embedded in the payload, and that the password fingerprint still matches the stored hash (the single-use property). """ if not token: return None, None payload = verify_cookie( token, signing_key, max_age=MAX_TTL_SECONDS, salt=RESET_SALT ) if not payload: return None, None parts = payload.split(_SEP, 2) if len(parts) != 3: return None, None fingerprint, expires_raw, email = parts try: expires_at = int(expires_raw) except ValueError: return None, None if expires_at <= time.time(): return None, None try: record = user_store[email] except KeyError: return None, None if not isinstance(record, dict) or password_fingerprint(record) != fingerprint: return None, None return email, record
[docs] def reset_url(base_url: str, token: str) -> str: """Return the full ``/auth/reset-password`` link carrying ``token``.""" from urllib.parse import quote return f"{base_url.rstrip('/')}/auth/reset-password?token={quote(token, safe='')}"