feat(BIZ-129): 方案 C 用户系统 DB 层 + 业务层(BIZ-127 #1/#2/#5/#6/#8 对齐)

- auth_db.py: users/sessions/password_reset_tokens/operation_logs 4 表 + migrate_legacy_schema 增量升级
- auth.py: hash_password(rounds=12) + create_session(token_hash=sha256) + authenticate(锁定→清零) + cleanup_op_logs(90d)
- 业务层已就绪,未完成:auth_api 蓝图 / require_auth 装饰器 / 限流 / pytest / 前端 3 页

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
costcodev
2026-09-05 01:31:34 +08:00
parent dcc7339384
commit ab9b62e9b3
2 changed files with 723 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用户系统业务逻辑 — BIZ-129 方案 C
封装:
- 密码哈希(bcrypt)
- 用户 CRUD(创建/查询/校验)
- Session 创建/校验/撤销
- 密码重置 token 生成/校验
- 操作日志写入
"""
import os
import json
import secrets
import sqlite3
import hashlib
import bcrypt
from datetime import datetime, timedelta
from auth_db import get_conn, init_db, now_iso
# ============================================================
# 常量(BIZ-127 8 条约束对齐)
# ============================================================
# #1 bcrypt cost 显式锁 12,防止未来库升级改了默认值
BCRYPT_ROUNDS = 12
# #6 登录失败锁定策略
LOGIN_MAX_FAILED = 5 # 连续 5 次失败锁
LOGIN_LOCKOUT_MINUTES = 15 # 锁 15 分钟
# #8 op_logs 保留天数(cleanup 默认值)
OP_LOG_RETENTION_DAYS = 90
# ============================================================
# 常量
# ============================================================
SESSION_COOKIE_NAME = 'lotto_session'
SESSION_LIFETIME_DAYS = 30
RESET_TOKEN_LIFETIME_HOURS = 2
RESET_TOKEN_BYTES = 32
SESSION_ID_BYTES = 32
def _sha256(s: str) -> str:
"""#2 session 存 hash 入库:sha256(cookie_value).hexdigest()。"""
return hashlib.sha256(s.encode('utf-8')).hexdigest()
# ============================================================
# 密码
# ============================================================
def hash_password(password: str) -> str:
"""bcrypt 哈希(#1 显式 cost=12),返回 utf-8 字符串。"""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS),
).decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except (ValueError, TypeError):
return False
# ============================================================
# 用户
# ============================================================
def create_user(username: str, password: str, role: str = 'user') -> int:
"""创建用户,返回 user_id。username 必须唯一。"""
init_db()
pw_hash = hash_password(password)
created_at = now_iso()
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO users (username, password_hash, role, is_active, created_at) "
"VALUES (?, ?, ?, 1, ?)",
(username, pw_hash, role, created_at),
)
return cur.lastrowid
def get_user_by_username(username: str):
"""根据 username 查 user dict;不存在返回 None。"""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def get_user_by_id(user_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def count_users() -> int:
with get_conn() as conn:
row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
return row['c'] if row else 0
def update_password(user_id: int, new_password: str):
pw_hash = hash_password(new_password)
with get_conn() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?", (pw_hash, user_id)
)
def touch_last_login(user_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), user_id)
)
# ============================================================
# Session
# ============================================================
def create_session(user_id: int, ip: str = '', user_agent: str = '') -> dict:
"""创建新 session,返回 {session_id, expires_at}。
#2 BIZ-127:DB 存 token_hash(sha256(cookie_value)),cookie 只下发随机串。
session_id 字段名保留作为外部 cookie 值,token_hash 是入库列。
"""
init_db()
session_id = secrets.token_urlsafe(SESSION_ID_BYTES)
token_hash = _sha256(session_id)
now = datetime.utcnow()
expires = now + timedelta(days=SESSION_LIFETIME_DAYS)
with get_conn() as conn:
conn.execute(
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
token_hash, user_id,
now.isoformat(), expires.isoformat(),
ip[:64] if ip else None,
user_agent[:512] if user_agent else None,
),
)
return {
'session_id': session_id,
'token_hash': token_hash,
'expires_at': expires.strftime('%Y-%m-%dT%H:%M:%S'),
}
def lookup_session(session_id: str):
"""校验 session_id,返回 user dict(含 session 元数据)或 None。
#2 BIZ-127:按 sha256(session_id) 即 token_hash 入库对比,
外部传入明文 cookie,内部永远不存明文。
"""
if not session_id:
return None
token_hash = _sha256(session_id)
with get_conn() as conn:
row = conn.execute(
"""
SELECT s.token_hash, s.user_id, s.expires_at, s.ip,
u.username, u.role, u.is_active
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.revoked = 0
AND u.is_active = 1
""",
(token_hash,),
).fetchone()
if not row:
return None
try:
expires = datetime.fromisoformat(row['expires_at'])
except (ValueError, TypeError):
return None
if expires < datetime.utcnow():
# 过期 — 标记撤销
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE token_hash = ?", (token_hash,)
)
return None
return {
'session_id': session_id,
'token_hash': token_hash,
'user_id': row['user_id'],
'username': row['username'],
'role': row['role'],
'expires_at': row['expires_at'],
'ip': row['ip'],
}
def revoke_session(session_id: str):
if not session_id:
return
token_hash = _sha256(session_id)
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE token_hash = ?", (token_hash,)
)
def cleanup_expired_sessions():
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE revoked = 0 AND expires_at < ?",
(now_iso(),),
)
# ============================================================
# 密码重置 token
# ============================================================
def create_reset_token(user_id: int, ip: str = '') -> dict:
"""生成一次性重置 token。"""
token = secrets.token_urlsafe(RESET_TOKEN_BYTES)
now = datetime.utcnow()
expires = now + timedelta(hours=RESET_TOKEN_LIFETIME_HOURS)
with get_conn() as conn:
conn.execute(
"INSERT INTO password_reset_tokens (user_id, token, created_at, expires_at, ip) "
"VALUES (?, ?, ?, ?, ?)",
(user_id, token, now.isoformat(), expires.isoformat(), ip[:64] if ip else None),
)
return {
'token': token,
'expires_at': expires.strftime('%Y-%m-%dT%H:%M:%S'),
'lifetime_hours': RESET_TOKEN_LIFETIME_HOURS,
}
def consume_reset_token(token: str):
"""校验并消费重置 token。返回 user dict 或 None。
校验规则:存在、未过期、未使用。
消费规则:原子地将 used_at 写入;如果已被使用则返回 None。
"""
if not token:
return None
with get_conn() as conn:
row = conn.execute(
"""
SELECT t.id, t.user_id, t.expires_at, t.used_at,
u.username, u.is_active
FROM password_reset_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token = ?
""",
(token,),
).fetchone()
if not row:
return None
if row['used_at']:
return None
try:
expires = datetime.fromisoformat(row['expires_at'])
except (ValueError, TypeError):
return None
if expires < datetime.utcnow():
return None
if not row['is_active']:
return None
# 原子消费
cur = conn.execute(
"UPDATE password_reset_tokens SET used_at = ? "
"WHERE id = ? AND used_at IS NULL",
(now_iso(), row['id']),
)
if cur.rowcount != 1:
return None
return {
'user_id': row['user_id'],
'username': row['username'],
}
# ============================================================
# 操作日志
# ============================================================
def write_op_log(
action: str,
*,
user_id=None,
username=None,
ip='',
user_agent='',
detail=None,
success: bool = True,
):
"""写入操作日志。detail 可以是 dict(会被 JSON 化)。"""
detail_str = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else (detail or '')
with get_conn() as conn:
conn.execute(
"""
INSERT INTO operation_logs
(user_id, username, action, ip, user_agent, detail, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id, username, action,
ip[:64] if ip else None,
user_agent[:512] if user_agent else None,
detail_str[:2000] if detail_str else None,
1 if success else 0,
now_iso(),
),
)
def list_recent_op_logs(limit: int = 50, action: str = None):
"""查询最近操作日志(管理员用)。"""
limit = max(1, min(int(limit), 200))
with get_conn() as conn:
if action:
rows = conn.execute(
"SELECT * FROM operation_logs WHERE action = ? "
"ORDER BY id DESC LIMIT ?",
(action, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM operation_logs ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
# ============================================================
# 登录业务层(#6 失败锁定 BIZ-127)
# ============================================================
def is_account_locked(user_row) -> bool:
"""检查账号是否在锁定期内。返回 True 表示被锁。"""
if not user_row:
return False
locked_until = user_row.get('locked_until')
if not locked_until:
return False
try:
until = datetime.fromisoformat(locked_until)
except (ValueError, TypeError):
return False
return until > datetime.utcnow()
def _lock_account(user_id: int):
"""锁定账号 15 分钟,重置失败计数。"""
until = datetime.utcnow() + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = 0, locked_until = ? WHERE id = ?",
(until.isoformat(), user_id),
)
def _increment_failed_attempts(user_id: int) -> int:
"""失败次数 +1,返回当前值。"""
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?",
(user_id,),
)
row = conn.execute(
"SELECT failed_attempts FROM users WHERE id = ?", (user_id,)
).fetchone()
return int(row['failed_attempts']) if row else 0
def _clear_failed_attempts(user_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
(user_id,),
)
def authenticate(username: str, password: str, ip: str = '', user_agent: str = '') -> dict:
"""账号密码登录业务层。
严格顺序(#6 BIZ-127):
1. 查 user → 不存在返 None(记 login_fail 日志)
2. 检查 locked_until → 已锁返 'locked'(记 login_fail 日志)
3. 检查 is_active → 禁用返 None(记 login_fail 日志)
4. bcrypt verify → 失败计数 +1,达 5 次锁 15 分钟(记 login_fail 日志)
5. 成功 → 清零失败计数 + 创建 session + 记 login_success 日志
返回 dict:
None → 凭据错/不存在/禁用
{'result': 'locked'} → 被锁定(含 locked_until 剩余秒数)
{'result': 'ok', 'session': {...}, 'user': {...}}
"""
init_db()
user = get_user_by_username(username)
if not user:
write_op_log(
'login_fail', username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'user_not_found'}, success=False,
)
return None
if is_account_locked(user):
# 计算剩余秒数
try:
until = datetime.fromisoformat(user['locked_until'])
remaining = int((until - datetime.utcnow()).total_seconds())
except Exception:
remaining = LOGIN_LOCKOUT_MINUTES * 60
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'account_locked', 'remaining_seconds': remaining},
success=False,
)
return {'result': 'locked', 'locked_until': user['locked_until'], 'remaining_seconds': remaining}
if not user.get('is_active'):
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'inactive'}, success=False,
)
return None
if not verify_password(password, user['password_hash']):
cnt = _increment_failed_attempts(user['id'])
if cnt >= LOGIN_MAX_FAILED:
_lock_account(user['id'])
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'wrong_password', 'locked': True, 'failed_attempts': cnt},
success=False,
)
return {'result': 'locked', 'reason': 'too_many_failures',
'locked_until': (datetime.utcnow() + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)).isoformat()}
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'wrong_password', 'failed_attempts': cnt},
success=False,
)
return None
# 成功
_clear_failed_attempts(user['id'])
touch_last_login(user['id'])
session = create_session(user['id'], ip=ip, user_agent=user_agent)
write_op_log(
'login_success', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'session_token_hash_prefix': session['token_hash'][:12]},
success=True,
)
return {
'result': 'ok',
'session': session,
'user': {
'id': user['id'],
'username': user['username'],
'role': user['role'],
'tenant_id': user.get('tenant_id'),
},
}
# ============================================================
# 操作日志清理(#8 BIZ-127,90 天保留)
# ============================================================
def cleanup_op_logs(days: int = OP_LOG_RETENTION_DAYS) -> int:
"""删除早于 days 天的操作日志,返回删除行数。
应由 cron 入口每日调用(例如每天凌晨 3:00)。
"""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
with get_conn() as conn:
cur = conn.execute("DELETE FROM operation_logs WHERE created_at < ?", (cutoff,))
return cur.rowcount