后端 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>
55 lines
1.9 KiB
Python
55 lines
1.9 KiB
Python
"""登录失败限流。
|
||
|
||
键 `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))
|