Files
Lottery/auth_api.py
T
costcodev 111b56f2ee test(BIZ-129): pytest 端到端 19/19 全绿 + 修 reset-confirm bug
测试覆盖:
- login: 成功 / 错密码 / 缺失字段 / 未知用户 (4)
- me: 未登录 / 已登录 / fake cookie → session_expired (3)
- logout: 清 session (1)
- change-password: 成功(强制重新登录)/ 旧密码错 / 密码过短 (3)
- reset-request: 已存在用户 / 未知用户(防枚举)/ 完整重置流程 / 无效 token (4)
- 限流: login 6/min 触发 429 + reset-request 4/min 触发 429 (2)
- 安全约束: session 存 sha256 hash + bcrypt $2b$12$ (2)

修复 bug:
- reset-confirm 把 consume_reset_token 返回的 dict 当 user_id 传给 update_password
  → 修复为 user_info['user_id']

测试结果:19 passed, 65 warnings in 18.34s

依赖:b002e92(7 路由)+ 44f46a0(限流)+ 1df6e7f(前端)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 07:22:31 +08:00

447 lines
15 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
认证 API 蓝图(BIZ-129 方案 C)
实现 BIZ-127 架构决策 #1/#2/#5/#6/#8:
- login / logout / refresh / me / change-password
- reset-request / reset-confirm(密码重置两段式)
约束(来自陆怀瑾 23:38 5 条):
1. 会话存 hash 入库(token_hash = sha256(cookie_value))
2. cookie 走 HttpOnly + SameSite=Lax(Secure 由部署层决定)
3. 5 次失败锁定 15 分钟
4. 重置 token 一次性 + 2h 过期 + token_urlsafe(32)
5. 操作日志保留 90 天 + login_success/login_fail 必记
依赖:auth.py 业务层(16 函数)+ auth_db.py(init_db)
设计原则:
- 蓝图独立文件,不污染 app.py 业务路由
- 错误返回统一格式:{'success': False, 'error': str, 'code': str}
- 成功返回统一格式:{'success': True, 'data': {...}}
- require_session 装饰器从 cookie 提取 session_id → sha256 查 sessions 表
- 限流留给 commit 2(Flask-Limiter 集成)
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta
from functools import wraps
from flask import Blueprint, current_app, jsonify, request, make_response
from auth_db import init_db, get_conn, now_iso
from auth import (
SESSION_COOKIE_NAME,
SESSION_LIFETIME_DAYS,
RESET_TOKEN_LIFETIME_HOURS,
authenticate,
create_session,
create_reset_token,
consume_reset_token,
get_user_by_id,
lookup_session,
revoke_session,
update_password,
verify_password,
write_op_log,
_sha256,
)
auth_bp = Blueprint('auth_api', __name__, url_prefix='/api/auth')
# ============================================================
# 限流配置(commit 2 整合 Flask-Limiter)
# ============================================================
# 限流维度:按 IP + 路径 组合
# - /login: 5 次/分钟(防爆破;5 次错就锁 15min 是账号级,这里是 IP 级双重防御)
# - /reset-request: 3 次/分钟(防滥用邮件/短信)
# - 其他:默认 60 次/分钟(防止爬虫)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# limiter 实例由 app.py 的 register_auth_api(limiter) 注入;这里先给默认 None 占位
_limiter = None
def _get_limiter():
return _limiter
def _rate_limit(rate: str, methods=None):
"""装饰器:限流(手写内存计数器,避免 Flask-Limiter 在测试 client 下不生效的坑)。
实现:按 (IP, view_function_name) 为 key,滚动 60 秒窗口。
- 解析 "N per minute" 中的 N 作为上限
- 多进程部署需切 Redis(架构选型问题,留待后续)
- limiter 仅为占位变量(保留扩展点),不影响计数逻辑
"""
from functools import wraps
methods = methods or ['POST']
parts = rate.split()
try:
max_count = int(parts[0])
except (ValueError, IndexError):
max_count = 5
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
# request 阶段查 limiter(仅为占位验证,不阻断)
current_limiter = _limiter
from flask import request as _req
ip = (_req.headers.get('X-Forwarded-For') or _req.remote_addr or 'unknown').split(',')[0].strip()
key = f'{ip}:{f.__name__}'
import time
now = time.time()
bucket = _rl_state.setdefault(key, [])
# 清 60s 以前的
cutoff = now - 60
while bucket and bucket[0] < cutoff:
bucket.pop(0)
if len(bucket) >= max_count:
from flask import jsonify
resp = jsonify({
'success': False,
'error': f'请求过于频繁,请 {60 - int(now - bucket[0])} 秒后再试',
'code': 'rate_limited',
})
resp.status_code = 429
return resp
bucket.append(now)
return f(*args, **kwargs)
return wrapper
return decorator
# 限流内存计数(key = "IP:endpoint_name",value = timestamp list)
_rl_state: dict = {}
def register_auth_api(app, limiter=None):
"""注册认证蓝图 + 记录 limiter 实例。
Args:
app: Flask app
limiter: 可选 flask_limiter.Limiter 实例。仅作为“能力标记”记录;
实际限流由 _rate_limit 装饰器手动实现(见 _rl_state)。
"""
global _limiter
_limiter = limiter
app.register_blueprint(auth_bp)
# ============================================================
# 工具函数
# ============================================================
def _client_ip() -> str:
"""取客户端 IP,优先 X-Forwarded-For(反代环境)。"""
return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip()
def _user_agent() -> str:
return request.headers.get('User-Agent', '')[:512] # 截断防爆库
def _ok(data: dict, status: int = 200):
return jsonify({'success': True, 'data': data}), status
def _err(message: str, code: str, status: int):
return jsonify({'success': False, 'error': message, 'code': code}), status
def _set_session_cookie(response, session_id: str):
"""设置 HttpOnly + SameSite=Lax cookie。Secure 留给部署层(nginx 等)。"""
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
max_age=SESSION_LIFETIME_DAYS * 24 * 3600,
httponly=True,
samesite='Lax',
path='/',
)
return response
def _clear_session_cookie(response):
response.delete_cookie(SESSION_COOKIE_NAME, path='/')
return response
# ============================================================
# 装饰器:从 cookie 取 session_id → 校验 → 注入 user 到 g
# ============================================================
def require_session(f):
@wraps(f)
def decorated(*args, **kwargs):
init_db() # 确保表存在(首次启动兼容)
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
if not session_id:
return _err('未登录', 'unauthenticated', 401)
sess = lookup_session(session_id)
if not sess:
# cookie 失效(过期 / 被吊销 / 不存在)
resp = make_response(_err('会话已过期,请重新登录', 'session_expired', 401))
_clear_session_cookie(resp)
return resp
user = get_user_by_id(sess['user_id'])
if not user or not user.get('is_active'):
resp = make_response(_err('账号不可用', 'user_inactive', 401))
revoke_session(session_id)
_clear_session_cookie(resp)
return resp
# 注入到 flask.g(在 view 里 g.current_user 可用)
from flask import g
g.current_session = sess
g.current_user = user
return f(*args, **kwargs)
return decorated
# ============================================================
# POST /api/auth/login
# ============================================================
@auth_bp.route('/login', methods=['POST'])
@_rate_limit('5 per minute', methods=['POST'])
def login():
init_db()
payload = request.get_json(silent=True) or {}
username = (payload.get('username') or '').strip()
password = payload.get('password') or ''
if not username or not password:
return _err('用户名和密码必填', 'missing_credentials', 400)
result = authenticate(
username=username, password=password,
ip=_client_ip(), user_agent=_user_agent(),
)
if result is None:
return _err('用户名或密码错误', 'invalid_credentials', 401)
if result.get('result') == 'locked':
return _err(
f"账号已锁定,请 {result.get('remaining_seconds', 0) // 60 + 1} 分钟后再试",
'account_locked',
429,
)
sess = result['session']
user = result['user']
resp = make_response(_ok({
'user': {
'id': user['id'],
'username': user['username'],
'role': user['role'],
},
'expires_at': sess['expires_at'],
}))
_set_session_cookie(resp, sess['session_id'])
return resp
# ============================================================
# POST /api/auth/logout
# ============================================================
@auth_bp.route('/logout', methods=['POST'])
@require_session
def logout():
from flask import g
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
user = g.current_user
revoke_session(session_id)
write_op_log(
'logout', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
resp = make_response(_ok({'logged_out': True}))
_clear_session_cookie(resp)
return resp
# ============================================================
# GET /api/auth/me(前端启动时校验登录态)
# ============================================================
@auth_bp.route('/me', methods=['GET'])
@require_session
def me():
from flask import g
user = g.current_user
return _ok({
'id': user['id'],
'username': user['username'],
'role': user['role'],
'last_login_at': user.get('last_login_at'),
})
# ============================================================
# POST /api/auth/change-password(已登录用户改密码)
# ============================================================
@auth_bp.route('/change-password', methods=['POST'])
@require_session
def change_password():
from flask import g
payload = request.get_json(silent=True) or {}
old_pw = payload.get('old_password') or ''
new_pw = payload.get('new_password') or ''
if not old_pw or not new_pw:
return _err('旧密码和新密码必填', 'missing_password', 400)
if len(new_pw) < 8:
return _err('新密码至少 8 位', 'weak_password', 400)
if old_pw == new_pw:
return _err('新密码不能与旧密码相同', 'same_password', 400)
user = g.current_user
# 验证旧密码
user_row = get_user_by_id(user['id']) # 取完整行(含 password_hash)
if not user_row or not verify_password(old_pw, user_row['password_hash']):
write_op_log(
'change_password_fail', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'wrong_old_password'}, success=False,
)
return _err('旧密码错误', 'wrong_old_password', 401)
update_password(user['id'], new_pw)
# 改完密码吊销当前 session(强制重新登录)
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
revoke_session(session_id)
write_op_log(
'change_password', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
resp = make_response(_ok({'changed': True}))
_clear_session_cookie(resp)
return resp
# ============================================================
# POST /api/auth/reset-request(申请重置 token,不要求登录)
# ============================================================
@auth_bp.route('/reset-request', methods=['POST'])
@_rate_limit('3 per minute', methods=['POST'])
def reset_request():
init_db()
payload = request.get_json(silent=True) or {}
username = (payload.get('username') or '').strip()
if not username:
return _err('用户名必填', 'missing_username', 400)
# 安全考虑:无论用户是否存在,都返回 ok(避免用户名枚举)
# 但仅在用户存在时实际生成 token + 写日志
user = None
from auth_db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT id, username FROM users WHERE username = ?", (username,)).fetchone()
if row:
user = {'id': row['id'], 'username': row['username']}
if user:
token_info = create_reset_token(user['id'], ip=_client_ip())
write_op_log(
'reset_request', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
# 注:实际项目里 token 通过邮件/短信下发,这里直接返回便于测试 + 后续接邮件
# 生产环境应去掉 token 字段,仅返回 ok
return _ok({
'requested': True,
# 'token': token_info['token'], # 生产关闭
'lifetime_hours': token_info['lifetime_hours'],
})
else:
# 用户不存在 — 写一条无 user_id 的日志便于审计
write_op_log(
'reset_request_fail', user_id=None, username=username,
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'user_not_found'}, success=False,
)
return _ok({'requested': True}) # 同样返回 ok(防枚举)
# ============================================================
# POST /api/auth/reset-confirm(凭 token 重置密码)
# ============================================================
@auth_bp.route('/reset-confirm', methods=['POST'])
@_rate_limit('5 per minute', methods=['POST'])
def reset_confirm():
init_db()
payload = request.get_json(silent=True) or {}
token = payload.get('token') or ''
new_pw = payload.get('new_password') or ''
if not token or not new_pw:
return _err('token 和新密码必填', 'missing_fields', 400)
if len(new_pw) < 8:
return _err('新密码至少 8 位', 'weak_password', 400)
user_info = consume_reset_token(token)
if not user_info:
write_op_log(
'reset_confirm_fail', user_id=None, username=None,
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'invalid_or_expired_token'}, success=False,
)
return _err('token 无效或已过期', 'invalid_token', 400)
user_id = user_info['user_id']
username = user_info.get('username')
update_password(user_id, new_pw)
# 重置后吊销该用户所有 session(强制重新登录)
with get_conn() as conn:
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
write_op_log(
'reset_confirm', user_id=user_id, username=username,
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
return _ok({'reset': True})
# ============================================================
# POST /api/auth/refresh(续期当前 session,过期前 7 天可刷)
# ============================================================
@auth_bp.route('/refresh', methods=['POST'])
@require_session
def refresh():
from flask import g
sess = g.current_session
user = g.current_user
# 仅在剩余寿命 < 7 天时续期,避免每次请求都写 DB
try:
expires = datetime.fromisoformat(sess['expires_at'])
remaining = (expires - datetime.utcnow()).total_seconds()
except Exception:
remaining = 0
if remaining > 7 * 24 * 3600:
return _ok({'refreshed': False, 'expires_at': sess['expires_at']})
new_expires = (datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS)).isoformat()
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET expires_at = ? WHERE id = ?",
(new_expires, sess['id']),
)
return _ok({'refreshed': True, 'expires_at': new_expires})
# 蓝图注册入口已定义在文件顶部 register_auth_api(app, limiter=None)
# 避免重复定义(早期版本残留已清理)