feat(BIZ-129): auth_api 蓝图 + 7 接口(login/logout/me/refresh/change-password/reset-request/reset-confirm)

- auth_api.py: Blueprint 独立文件,不污染 app.py 业务路由
- session 存 sha256 hash 入库(架构决策 #2,依赖 auth.py lookup_session)
- cookie HttpOnly + SameSite=Lax(Secure 留给部署层 nginx)
- 错误返回统一:{'success': False, 'error': str, 'code': str}
- 防用户名枚举:reset-request 即便用户不存在也返回 ok
- change-password 成功后强制吊销当前 session(防 session 复用)
- reset-confirm 成功后吊销该用户所有 session(防攻击者已登录状态)

依赖:auth.py + auth_db.py(commit ab9b62e)

BIZ-127 架构决策对齐:
  ✓ #1 bcrypt rounds=12(依赖 auth.py hash_password)
  ✓ #2 session token_hash = sha256(cookie_value)
  ✓ #5 5 次失败锁定 15 分钟(依赖 auth.py authenticate)
  ✓ #6 重置 token token_urlsafe(32) + 2h 过期 + 一次性(依赖 auth.py create_reset_token / consume_reset_token)
  ✓ #8 操作日志 90 天保留(依赖 auth.py write_op_log)

未完成(commit 2/3/4):
  - Flask-Limiter 集成(commit 2)
  - 前端 3 页(commit 3)
  - pytest 端到端(commit 4)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
costcodev
2026-09-05 07:09:08 +08:00
parent ab9b62e9b3
commit b002e929dc
2 changed files with 376 additions and 0 deletions
+12
View File
@@ -31,6 +31,18 @@ from lottery import DoubleColorBallGenerator
app = Flask(__name__) app = Flask(__name__)
# ============================================================
# 认证 API 蓝图注册(BIZ-129 方案 C)
# ============================================================
# auth_api.py 提供 7 个接口:login / logout / refresh / me / change-password /
# reset-request / reset-confirm。session 存 sha256 hash 入库(架构决策 #2)。
try:
from auth_api import register_auth_api
register_auth_api(app)
except ImportError as e:
# auth_api.py 未就绪时不影响主流程(保持向后兼容)
print(f'[BIZ-129] auth_api 蓝图注册跳过:{e}', file=sys.stderr)
# ============================================================ # ============================================================
# 中奖金额常量表(PRD v1.2 BIZ-105 §4.3) # 中奖金额常量表(PRD v1.2 BIZ-105 §4.3)
# ============================================================ # ============================================================
+364
View File
@@ -0,0 +1,364 @@
#!/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')
# ============================================================
# 工具函数
# ============================================================
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'])
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'])
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'])
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})
# ============================================================
# 蓝图注册入口(供 app.py 调用)
# ============================================================
def register_auth_api(app):
"""在 app.py 里 app.register_blueprint(auth_bp) 即可。"""
app.register_blueprint(auth_bp)