92 lines
3.6 KiB
Python
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}>'
|