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''