05/17 enhance codes
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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}>'
|
||||
Reference in New Issue
Block a user