后端 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>
49 lines
1.9 KiB
Python
49 lines
1.9 KiB
Python
"""图形验证码的签发与校验。
|
||
|
||
校验使用 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()
|