PayrollManagementSystem/backend/app/services/login_limiter.py
liuwenxin 273ebf4657 chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-09 18:46:36 +08:00

55 lines
1.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""登录失败限流。
键 `ys:login:fail:{username}` 记录连续失败次数,TTL 等于锁定时长。
达到阈值后即使密码正确也拒绝登录,须等锁定期结束。
**为什么按用户名而非 IP**:本系统用户是确定的几十个营销人员,
按用户名限流才能防住针对性撞库;按 IP 会被内网 NAT 和攻击者换 IP 绕过。
代价是攻击者可故意把他人账号锁 15 分钟 —— 该风险在内部系统中可接受,
且管理员可通过 `clear()` 手动解锁。
"""
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_LOGIN_FAIL
from app.core.redis_client import get_redis
def _key(username: str) -> str:
return REDIS_KEY_LOGIN_FAIL.format(username=username)
def failure_count(username: str) -> int:
"""当前连续失败次数。键不存在时为 0。"""
value = get_redis().get(_key(username))
return int(value) if value else 0
def is_locked(username: str) -> bool:
"""账号当前是否处于锁定期。"""
return failure_count(username) >= get_settings().login_fail_limit
def remaining_lock_seconds(username: str) -> int:
"""锁定期剩余秒数。未锁定时为 0。"""
ttl = get_redis().ttl(_key(username))
return ttl if ttl and ttl > 0 else 0
def record_failure(username: str) -> int:
"""记录一次登录失败,返回累计次数。
每次失败都刷新 TTL,形成**滑动窗口**:任意 15 分钟区间内累计 5 次即锁定。
若不刷新,则"4 次失败 + 等 14 分钟 + 1 次失败"就不会触发锁定,
正好给撞库留出缝隙。
"""
key = _key(username)
redis = get_redis()
count = int(redis.incr(key))
redis.expire(key, get_settings().login_lock_minutes * 60)
return count
def clear(username: str) -> None:
"""登录成功后清除失败计数。"""
get_redis().delete(_key(username))