44f46a0e40
- requirements.txt: flask-limiter>=4.0
- app.py: 创建 Limiter 实例(memory:// 存储,60/分钟默认)
- auth_api.py:
- 简化 register_auth_api(app, limiter=None)——limiter 仅作能力标记
- _rate_limit(rate, methods) 装饰器:手写 IP+endpoint 滚动窗口限流
(Flask-Limiter 在测试 client 下不生效,故手动实现;多进程需切换 Redis)
- login / reset-request / reset-confirm 三个路由应用装饰器
限流策略(架构决策 #5/#6 配合):
- /login: 5 次/分钟(IP 级爆破防御,配合账号级 5 次锁定 15min)
- /reset-request: 3 次/分钟(防邮件/短信滥用)
- /reset-confirm: 5 次/分钟(防 token 暴力猜解)
测试结果(test_client 压测):
- login: 1-5 次业务 401,6 次起 429 ✅
- reset-request: 1-3 次业务 200,4 次起 429 ✅
依赖:commit b002e92 的 7 路由蓝图。
BIZ-127 架构决策:
✓ #5 限流 5 次/分钟(IP 级,与账号级锁定 15min 双重防御)
Co-authored-by: multica-agent <github@multica.ai>
445 lines
15 KiB
Python
445 lines
15 KiB
Python
#!/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_id = consume_reset_token(token)
|
||
if not user_id:
|
||
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)
|
||
|
||
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=None,
|
||
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)
|
||
# 避免重复定义(早期版本残留已清理)
|