Aug 26 - Update password detect against off field 2
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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'<LoginAttempt user={self.user_id} ip={self.ip_address} n={self.failed_count}>'
|
||||
Reference in New Issue
Block a user