login_lockout.py 2.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. """登录失败锁定策略:连续密码错误达到阈值后临时锁定账号。
  2. - 仅 /auth/login 的密码错误计入(用户名不存在、账号停用、IP受限不计入);
  3. - 锁定期间登录直接拒绝(ACCOUNT_LOCKED),不延长锁定窗口;
  4. - 锁定到期自动解除;登录成功即清零计数与锁定。
  5. """
  6. from __future__ import annotations
  7. from datetime import datetime, timedelta, timezone
  8. from sqlalchemy import select
  9. from dms.database.transaction import for_update, transaction
  10. from dms.extensions import db
  11. from dms.models import User
  12. LOGIN_MAX_FAILURES = 5
  13. LOGIN_LOCK_DURATION = timedelta(minutes=15)
  14. def _utc_now() -> datetime:
  15. return datetime.now(timezone.utc).replace(tzinfo=None)
  16. def is_locked(user: User) -> bool:
  17. """账号当前是否处于锁定窗口内(已过期的锁定视为未锁定)。"""
  18. return user.locked_until is not None and user.locked_until > _utc_now()
  19. def apply_failed_attempt(user: User, *, now: datetime | None = None) -> None:
  20. """纯字段变换:连续失败计数+1,达到阈值则锁定并清零计数。
  21. 不触碰数据库,方便单元测试;调用方负责 row_version 递增。
  22. """
  23. moment = now or _utc_now()
  24. user.failed_login_count = (user.failed_login_count or 0) + 1
  25. if user.failed_login_count >= LOGIN_MAX_FAILURES:
  26. user.locked_until = moment + LOGIN_LOCK_DURATION
  27. user.failed_login_count = 0
  28. def register_failed_password_attempt(user: User) -> None:
  29. """密码校验失败后落库计数(独立短事务,对齐 record_login_failure)。"""
  30. db.session.rollback()
  31. with transaction() as session:
  32. row = session.scalar(
  33. for_update(select(User).where(User.id == user.id))
  34. )
  35. if row is None:
  36. return
  37. apply_failed_attempt(row)
  38. row.row_version += 1
  39. def clear_failed_attempts(user: User) -> None:
  40. """登录成功后清零计数与锁定;由调用方事务负责 row_version 递增。"""
  41. user.failed_login_count = 0
  42. user.locked_until = None