44 lines
1.4 KiB
Python
44 lines
1.4 KiB
Python
"""Password reset tokens (SPEC §6.1).
|
|||
|
|
|
||
|
|
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
|
||
|
|
recovery email on file. Actual delivery of the token to the recovery address
|
||
|
|
requires an app-level mailer (not part of M0/M1); until then the token is logged
|
||
|
|
server-side. See docs/open-questions.md.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import secrets
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
from app.redis_client import aw, get_redis
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
_PREFIX = "pwreset:"
|
||
|
|
_TTL = 30 * 60 # 30 minutes
|
||
|
|
|
||
|
|
|
||
|
|
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
|
||
|
|
"""Create a single-use reset token bound to ``user_id`` and return it."""
|
||
|
|
token = secrets.token_urlsafe(32)
|
||
|
|
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
|
||
|
|
# TODO(spec): deliver via the recovery address once an app mailer exists.
|
||
|
|
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
|
||
|
|
return token
|
||
|
|
|
||
|
|
|
||
|
|
async def consume_token(token: str) -> uuid.UUID | None:
|
||
|
|
"""Atomically consume a reset token, returning the user_id (or None)."""
|
||
|
|
if not token:
|
||
|
|
return None
|
||
|
|
redis = get_redis()
|
||
|
|
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
|
||
|
|
if raw is None:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return uuid.UUID(raw)
|
||
|
|
except ValueError:
|
||
|
|
return None
|