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>
This commit is contained in:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1,8 @@
|
||||
"""业务服务层:业务编排与事务边界。
|
||||
|
||||
分层约束:
|
||||
- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交,
|
||||
这样一次请求内的多次数据写入能共处同一事务
|
||||
- 可调用多个 repository 组合业务逻辑
|
||||
- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应
|
||||
"""
|
||||
@@ -0,0 +1,167 @@
|
||||
"""认证业务编排。
|
||||
|
||||
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
|
||||
|
||||
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
|
||||
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
|
||||
from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 统一凭据错误文案:不区分「用户不存在」与「密码错误」,防止账号枚举。
|
||||
# 攻击者据此无法判断某个用户名是否真实存在。
|
||||
INVALID_CREDENTIALS = "用户名或密码错误"
|
||||
|
||||
|
||||
def _record(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def login(
|
||||
session: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
captcha_id: str,
|
||||
captcha_code: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> LoginOut:
|
||||
"""执行登录,返回访问令牌与用户信息。
|
||||
|
||||
判定顺序是有意设计的,每一步对应一类攻击面:
|
||||
|
||||
1. **锁定检查放在最前** —— 否则攻击者无需通过验证码即可消耗失败计数
|
||||
2. **验证码校验并消费** —— 阻断自动化撞库
|
||||
3. **凭据校验** —— 用户不存在与密码错误共用同一文案
|
||||
4. **状态检查放在最后** —— 若提前到密码校验之前,会向未持有正确密码者
|
||||
暴露「该账号存在但已禁用」
|
||||
|
||||
:raises ForbiddenError: 账号锁定或已禁用(403)
|
||||
:raises BadRequestError: 验证码错误或凭据错误(400)
|
||||
"""
|
||||
# ---- 1. 锁定检查 ----
|
||||
if login_limiter.is_locked(username):
|
||||
remaining = login_limiter.remaining_lock_seconds(username)
|
||||
minutes = max(1, (remaining + 59) // 60) # 向上取整,避免显示"0 分钟"
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号锁定",
|
||||
)
|
||||
raise ForbiddenError(f"账号已锁定,请 {minutes} 分钟后重试")
|
||||
|
||||
# ---- 2. 验证码校验(无论成败,该验证码都已失效)----
|
||||
if not captcha_service.verify_and_consume(captcha_id, captcha_code):
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="验证码错误",
|
||||
)
|
||||
raise BadRequestError("验证码错误或已过期,请刷新验证码")
|
||||
|
||||
# ---- 3. 凭据校验 ----
|
||||
user = user_repository.get_by_username(session, username)
|
||||
if user is None or not verify_password(password, user.password):
|
||||
login_limiter.record_failure(username)
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id if user else None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason=INVALID_CREDENTIALS,
|
||||
)
|
||||
raise BadRequestError(INVALID_CREDENTIALS)
|
||||
|
||||
# ---- 4. 状态检查 ----
|
||||
if user.status != 1:
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号禁用",
|
||||
)
|
||||
raise ForbiddenError("账号已被禁用,请联系管理员")
|
||||
|
||||
# ---- 成功 ----
|
||||
login_limiter.clear(username)
|
||||
role_codes = user_repository.get_role_codes(user)
|
||||
# 库列存本地时间(容器 TZ=Asia/Shanghai);JWT 内部另用 UTC,两者不混算
|
||||
user_repository.touch_last_login(session, user, datetime.now())
|
||||
token, _, expires_in = create_access_token(
|
||||
user_id=user.id, token_version=user.token_version, roles=role_codes
|
||||
)
|
||||
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
|
||||
|
||||
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
|
||||
return LoginOut(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def logout(access_token: str) -> None:
|
||||
"""登出:把当前令牌加入黑名单。
|
||||
|
||||
令牌已自然过期或格式非法时**静默返回** —— 此时没有需要撤销的凭证,
|
||||
对调用方而言登出都是"已达成";返回 401 只会让前端多弹一次无意义的报错。
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(access_token)
|
||||
except AuthError:
|
||||
return
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if not jti or not exp:
|
||||
return
|
||||
|
||||
# 黑名单 TTL 必须等于令牌**剩余**有效期,写死固定值会导致"登出后又活了"
|
||||
remaining = int(exp - datetime.now(UTC).timestamp())
|
||||
token_blacklist.revoke(jti, remaining)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""图形验证码的签发与校验。
|
||||
|
||||
校验使用 Redis `GETDEL`——**原子取出即删**,同一验证码用两次必然失败,防重放。
|
||||
若写成「先 GET 再 DEL」,两条命令之间存在竞态窗口,并发请求可用同一验证码
|
||||
重复提交。
|
||||
|
||||
这是设计代价而非缺陷:用户输错一次密码后验证码已被消费,必须换一张。
|
||||
**前端必须在登录失败后自动刷新验证码**,否则第二次提交必然报「验证码错误」。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from app.core.captcha_image import generate_text, render_png_base64
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_CAPTCHA
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def issue() -> tuple[str, str, int]:
|
||||
"""签发验证码。
|
||||
|
||||
:returns: ``(captcha_id, PNG 的 base64, 有效期秒数)``
|
||||
图片与 ID 在同一次调用中成对产生,避免前端分两次请求导致错配。
|
||||
"""
|
||||
settings = get_settings()
|
||||
captcha_id = uuid.uuid4().hex
|
||||
text = generate_text()
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(
|
||||
REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id),
|
||||
text.upper(),
|
||||
ex=settings.captcha_expire_seconds,
|
||||
)
|
||||
return captcha_id, render_png_base64(text), settings.captcha_expire_seconds
|
||||
|
||||
|
||||
def verify_and_consume(captcha_id: str, code: str) -> bool:
|
||||
"""校验验证码并消费它。
|
||||
|
||||
**无论校验成败,该验证码都会失效** —— 通过 GETDEL 取出后键已被删除。
|
||||
这阻断了「用一个验证码反复试密码」的路径。
|
||||
|
||||
:returns: 校验是否通过。验证码不存在(过期或已被使用)时返回 False。
|
||||
"""
|
||||
expected = get_redis().getdel(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
if expected is None:
|
||||
return False
|
||||
return expected == code.strip().upper()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""登录失败限流。
|
||||
|
||||
键 `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))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""JWT 登出黑名单。
|
||||
|
||||
**TTL 必须等于令牌剩余有效期**:写死固定值会导致「登出 N 分钟后又活了」;
|
||||
不设过期则 Redis 会随登出次数无限增长。两者都是错的。
|
||||
|
||||
**范围限定**:本模块只解决「用户主动登出」。
|
||||
「禁用账号 / 改密 / 强制下线」由 `users.token_version` 承担 ——
|
||||
那种场景按用户维度写入 N 个黑名单键既低效,又会在 Redis 故障时完全失效
|
||||
(那时离职员工仍能用旧令牌查看全员薪酬)。鉴权时二者都要检查。
|
||||
"""
|
||||
|
||||
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def _key(jti: str) -> str:
|
||||
return REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti)
|
||||
|
||||
|
||||
def revoke(jti: str, remaining_seconds: int) -> None:
|
||||
"""把令牌加入黑名单。
|
||||
|
||||
:param jti: 令牌唯一标识
|
||||
:param remaining_seconds: 令牌剩余有效期;**必须传真实剩余值**。
|
||||
非正数表示令牌已自然过期,无需拉黑,本函数直接忽略。
|
||||
"""
|
||||
if remaining_seconds <= 0:
|
||||
return
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(_key(jti), "1", ex=remaining_seconds)
|
||||
|
||||
|
||||
def is_revoked(jti: str) -> bool:
|
||||
"""令牌是否已被拉黑。
|
||||
|
||||
Redis 不可用时本函数会抛异常 —— 这是**有意的 fail-closed**:
|
||||
对能查看全员薪酬的系统,宁可拒绝请求也不能放行无法确定状态的令牌。
|
||||
"""
|
||||
return bool(get_redis().exists(_key(jti)))
|
||||
Reference in new issue
Block a user