Files
PassKeeper/app/models/recovery_challenge.py
T
2026-05-17 18:29:13 -04:00

92 lines
3.6 KiB
Python

from datetime import datetime, timezone, timedelta
from sqlalchemy.dialects.mysql import INTEGER
from app import db
# Challenge TTL: the client has 15 minutes from /recovery/data to complete recovery.
CHALLENGE_TTL_MINUTES = 15
class RecoveryChallenge(db.Model):
"""
Server-side state for the account-recovery challenge-response flow.
Replaces the previous flask.session-based storage, which was not safe in
multi-worker Gunicorn deployments (each worker has its own in-process
session store, so a challenge written by worker A is invisible to worker B).
A row is created (or replaced) by GET /recovery/data and consumed
(deleted) exactly once by POST /recover or GET /recovery/items.
Rows older than CHALLENGE_TTL_MINUTES are ignored and cleaned up by the
same APScheduler job that handles token_blacklist.
Columns
-------
user_id FK → users.id (CASCADE DELETE)
nonce Random 64-hex-char string issued to the client.
expected_proof HMAC-SHA256(key=enc_key_salt, msg=nonce), hex-encoded.
Precomputed at challenge creation so the server never needs
to touch enc_key_salt again during verification.
expires_at Absolute UTC timestamp after which this challenge is void.
"""
__tablename__ = 'recovery_challenges'
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
user_id = db.Column(
INTEGER(unsigned=True),
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
unique=True, # one active challenge per user at a time
index=True,
)
nonce = db.Column(db.String(64), nullable=False)
expected_proof = db.Column(db.String(64), nullable=False)
expires_at = db.Column(db.DateTime, nullable=False)
@classmethod
def create(cls, user_id: int, nonce: str, expected_proof: str) -> 'RecoveryChallenge':
"""
Upsert: delete any existing challenge for this user, then create a fresh one.
Caller must call db.session.commit() after this.
"""
# Remove stale challenge so the UNIQUE constraint never fires on re-issue.
cls.query.filter_by(user_id=user_id).delete()
challenge = cls(
user_id=user_id,
nonce=nonce,
expected_proof=expected_proof,
expires_at=datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(minutes=CHALLENGE_TTL_MINUTES),
)
db.session.add(challenge)
return challenge
@classmethod
def consume(cls, user_id: int) -> 'RecoveryChallenge | None':
"""
Fetch and delete the challenge for user_id in one operation.
Returns the challenge if it exists and has not expired, otherwise None.
Caller must call db.session.commit() after this.
"""
challenge = cls.query.filter_by(user_id=user_id).first()
if not challenge:
return None
if challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
cls.query.filter_by(user_id=user_id).delete()
return None
# Delete before returning — one-time use.
cls.query.filter_by(user_id=user_id).delete()
return challenge
@classmethod
def cleanup_expired(cls) -> None:
"""Delete expired challenges — called by the APScheduler cleanup job."""
cls.query.filter(
cls.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None)
).delete()
db.session.commit()
def __repr__(self):
return f'<RecoveryChallenge user_id={self.user_id} expires={self.expires_at}>'