#!/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