from datetime import datetime, timedelta, timezone from sqlalchemy.dialects.mysql import INTEGER from app import db class LoginAttempt(db.Model): """ Failed-login tracking scoped to (account, source IP). The lockout used to live on the users table as a single global counter, which made it a denial-of-service primitive: anyone who knew an address could send five wrong passwords and lock the real owner out for 15 minutes, repeatedly and indefinitely. Locking someone out of their password manager is a serious harm on its own — it can mean losing access to everything at the worst possible moment — and it cost an attacker almost nothing. Scoping by IP means an attacker locks out only themselves. The victim signing in from their own address is unaffected. A distributed attacker has to rotate IPs, and each one is independently capped by Flask-Limiter (10/min on /login) plus the Nginx auth_limit zone. users.failed_login_count / users.locked_until still exist and are still maintained, but ONLY as an aggregate signal for the audit log and security dashboard. They no longer gate authentication — enforcement is here. """ __tablename__ = 'login_attempts' 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, ) # 45 chars covers IPv6; may be empty when the proxy supplies no address. ip_address = db.Column(db.String(45), nullable=False, default='') failed_count = db.Column(db.Integer, nullable=False, default=0, server_default='0') locked_until = db.Column(db.DateTime, nullable=True) updated_at = db.Column( db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), ) __table_args__ = ( db.UniqueConstraint('user_id', 'ip_address', name='uq_login_attempt_user_ip'), ) MAX_FAILED = 5 LOCKOUT_MINUTES = 15 # Rows older than this carry no information and are pruned by the scheduler. RETENTION_HOURS = 24 @staticmethod def _now(): return datetime.now(timezone.utc).replace(tzinfo=None) @classmethod def get(cls, user_id: int, ip_address: str): return cls.query.filter_by( user_id=user_id, ip_address=ip_address or '' ).first() @classmethod def is_locked(cls, user_id: int, ip_address: str) -> bool: """True if this IP is currently locked out of this account.""" row = cls.get(user_id, ip_address) if not row or not row.locked_until: return False if row.locked_until > cls._now(): return True # Expired — reset so the next failure starts a fresh count. row.failed_count = 0 row.locked_until = None row.updated_at = cls._now() return False @classmethod def record_failure(cls, user_id: int, ip_address: str) -> bool: """ Count a failed attempt. Returns True if this attempt triggered a lockout. Caller commits. """ now = cls._now() row = cls.get(user_id, ip_address) if row is None: row = cls(user_id=user_id, ip_address=ip_address or '', failed_count=0) db.session.add(row) row.failed_count = (row.failed_count or 0) + 1 row.updated_at = now if row.failed_count >= cls.MAX_FAILED: row.locked_until = now + timedelta(minutes=cls.LOCKOUT_MINUTES) return True return False @classmethod def clear(cls, user_id: int, ip_address: str) -> None: """Successful authentication — drop this IP's failure history.""" row = cls.get(user_id, ip_address) if row is not None: db.session.delete(row) @classmethod def cleanup_expired(cls) -> int: """Delete rows untouched for RETENTION_HOURS. Called by the scheduler.""" cutoff = cls._now() - timedelta(hours=cls.RETENTION_HOURS) return cls.query.filter(cls.updated_at <= cutoff).delete() def __repr__(self): return f''