""" 认证服务层 ========== 封装登录、刷新、登出等核心业务逻辑。 包含 SSE 连接池管理(用于单设备登录的实时踢人)。 """ from __future__ import annotations import asyncio import contextlib import hashlib import logging import random from typing import Any import httpx from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.redis_client import get_redis_client from app.core.security import ( create_access_token, create_refresh_token, verify_refresh_token, ) from app.crud.user import user as user_crud from app.crud.user_device import user_device as device_crud from app.models.user_device import UserDevice logger = logging.getLogger(__name__) # ── 短信业务常量(数值类配置不走 .env,内嵌代码)───── SMS_CODE_LENGTH = 6 # 验证码位数 SMS_CODE_EXPIRE_MINUTES = 5 # 验证码有效期(分钟) SMS_DAILY_LIMIT = 10 # 单手机号每日发送上限 # ========== SSE 连接池 ========== # key: user_id, value: asyncio.Queue(用于向该用户的 SSE 连接发送消息) _sse_connections: dict[str, asyncio.Queue[str]] = {} def register_sse_connection(user_id: str, queue: asyncio.Queue[str]) -> None: """注册用户的 SSE 连接""" _sse_connections[user_id] = queue def unregister_sse_connection(user_id: str) -> None: """注销用户的 SSE 连接""" _sse_connections.pop(user_id, None) async def _kick_old_device(user_id: str) -> None: """ 向旧设备的 SSE 连接发送踢人消息。 如果旧设备不在线(没有 SSE 连接),则静默忽略, 旧设备下次请求时会发现 Token 已失效。 """ queue = _sse_connections.get(user_id) if queue is None: return with contextlib.suppress(asyncio.QueueFull): # 发送 kick 消息,非阻塞(旧设备可能已断开) queue.put_nowait('{"type": "kick", "reason": "new_device_login"}') # 延迟注销,给旧设备一点时间处理消息 await asyncio.sleep(1) unregister_sse_connection(user_id) # ========== 验证码校验 ========== async def verify_sms_code(mobile: str, code: str) -> bool: """ 校验短信验证码。 从 Redis 读取 sms:{mobile} 的值,与传入的 code 比对。 校验成功后删除 Redis 中的验证码(防止重放攻击)。 """ redis = get_redis_client() key = f"sms:{mobile}" stored_code = await redis.get(key) if stored_code is None: return False if stored_code != code: return False # 校验成功,删除验证码(一次性使用) await redis.delete(key) return True async def send_sms_code(mobile: str) -> str: """ 发送短信验证码。 流程: 1. 检查每日发送上限(Redis) 2. 生成随机验证码 3. 存入 Redis(带过期时间) 4. 调用 B2M 短信平台发送(配置不完整时降级为日志打印) 返回生成的验证码。 """ redis = get_redis_client() key = f"sms:{mobile}" # 检查每日发送上限 daily_key = f"sms_daily:{mobile}" daily_count = await redis.get(daily_key) if daily_count and int(daily_count) >= SMS_DAILY_LIMIT: raise ValueError(f"该手机号今日验证码发送次数已达上限({SMS_DAILY_LIMIT}次)") # 生成验证码 code = "".join([str(random.randint(0, 9)) for _ in range(SMS_CODE_LENGTH)]) # nosec: B311 # 存入 Redis,设置过期时间 await redis.setex(key, SMS_CODE_EXPIRE_MINUTES * 60, code) # 更新每日计数 ttl = await redis.ttl(daily_key) if ttl < 0: # 新key或已过期,设置24小时过期 await redis.setex(daily_key, 24 * 60 * 60, 1) else: await redis.incr(daily_key) # 调用 B2M 短信平台(配置不完整时降级为日志打印) from app.services.sms_service import SMSError, get_sms_service try: sms = get_sms_service() if sms.app_id and sms.secret_key and sms.base_url: async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: await sms.send_verification_code( client, mobile=mobile, code=code, expire_minutes=SMS_CODE_EXPIRE_MINUTES, ) else: # 配置不完整,记录警告但不打印验证码 logger.warning( f"[SMS] B2M 短信配置不完整,验证码未发送: 手机号={mobile}" ) except SMSError as e: logger.error(f"[SMS] 短信发送失败: {e}") # 短信发送失败不影响验证码生成 except Exception as e: logger.exception(f"[SMS] 短信发送异常: {e}") return code # ========== Token 工具 ========== def _hash_refresh_token(token: str) -> str: """Refresh Token SHA256 哈希(用于数据库存储)""" return hashlib.sha256(token.encode()).hexdigest() # ========== 登录服务 ========== async def login_with_sms( db: AsyncSession, *, mobile: str, code: str, device_id: str, device_name: str | None = None, os_info: str | None = None, app_version: str | None = None, ip: str | None = None, source: str = "mobile_sms", ) -> dict[str, Any]: """ 手机号验证码登录。 流程: 1. 校验验证码 2. 获取或创建用户 3. 更新登录信息 4. 踢掉旧设备(SSE 推送) 5. 创建/覆盖设备记录 6. 签发双 Token """ # 1. 校验验证码(白名单内的手机号跳过校验) settings = get_settings() if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code): raise ValueError("验证码错误或已过期") # 2. 查询用户(不再自动注册) user = await user_crud.get_by_mobile(db, mobile=mobile) if user is None: raise ValueError("用户不存在") # 检查用户状态 if not user.is_active: raise ValueError("账号已被封禁,请联系客服") # 3. 更新登录信息 await user_crud.update_login_info(db, user_id=user.id, ip=ip) # 4. 踢掉旧设备(SSE 推送) await _kick_old_device(str(user.id)) # 5. 签发双 Token access_token = create_access_token(data={"sub": str(user.id)}) refresh_token = create_refresh_token(data={"sub": str(user.id)}) refresh_token_hash = _hash_refresh_token(refresh_token) # 6. 创建/覆盖设备记录 await device_crud.create_or_update( db, user_id=user.id, device_id=device_id, device_name=device_name, os_info=os_info, app_version=app_version, refresh_token_hash=refresh_token_hash, ) return { "access_token": access_token, "refresh_token": refresh_token, "user": { "id": user.id, "mobile": user.mobile, "nickname": user.nickname or "", "avatar": user.avatar_url or "", }, } async def refresh_access_token( db: AsyncSession, *, refresh_token: str, ) -> dict[str, str]: """ 用 Refresh Token 换取新的 Token 对(Token 轮换)。 流程: 1. 校验 Refresh Token 2. 查设备记录,匹配 refresh_token_hash 3. 生成新 Token 对 4. 更新设备记录的 refresh_token_hash """ # 1. 校验 Refresh Token payload = verify_refresh_token(refresh_token) if not payload or not payload.get("sub"): raise ValueError("Refresh Token 无效或已过期") user_id = payload["sub"] refresh_token_hash = _hash_refresh_token(refresh_token) # 2. 查设备记录 device = await device_crud.get_by_refresh_token_hash( db, refresh_token_hash=refresh_token_hash ) if device is None: raise ValueError("设备已失效,请重新登录") # 3. 生成新 Token 对 new_access_token = create_access_token(data={"sub": str(user_id)}) new_refresh_token = create_refresh_token(data={"sub": str(user_id)}) new_refresh_token_hash = _hash_refresh_token(new_refresh_token) # 4. 更新设备记录 await device_crud.create_or_update( db, user_id=user_id, device_id=device.device_id, device_name=device.device_name, os_info=device.os_info, app_version=device.app_version, refresh_token_hash=new_refresh_token_hash, ) return { "access_token": new_access_token, "refresh_token": new_refresh_token, } async def login_with_password( db: AsyncSession, *, mobile: str, password: str, device_id: str, device_name: str | None = None, os_info: str | None = None, app_version: str | None = None, ip: str | None = None, source: str = "mobile_password", ) -> dict[str, Any]: """ 手机号密码登录。 流程: 1. 查询用户 2. 校验密码 3. 更新登录信息 4. 踢掉旧设备(SSE 推送) 5. 创建/覆盖设备记录 6. 签发双 Token """ from app.core.security import verify_password # 1. 查询用户 user = await user_crud.get_by_mobile(db, mobile=mobile) if user is None: raise ValueError("用户不存在") # 2. 校验密码 if not user.password_hash: raise ValueError("该账号未设置密码,请使用验证码登录") if not verify_password(password, user.password_hash): raise ValueError("密码错误") # 检查用户状态 if not user.is_active: raise ValueError("账号已被封禁,请联系客服") # 3. 更新登录信息 await user_crud.update_login_info(db, user_id=user.id, ip=ip) # 4. 踢掉旧设备(SSE 推送) await _kick_old_device(str(user.id)) # 5. 签发双 Token access_token = create_access_token(data={"sub": str(user.id)}) refresh_token = create_refresh_token(data={"sub": str(user.id)}) refresh_token_hash = _hash_refresh_token(refresh_token) # 6. 创建/覆盖设备记录 await device_crud.create_or_update( db, user_id=user.id, device_id=device_id, device_name=device_name, os_info=os_info, app_version=app_version, refresh_token_hash=refresh_token_hash, ) return { "access_token": access_token, "refresh_token": refresh_token, "user": { "id": user.id, "mobile": user.mobile, "nickname": user.nickname or "", "avatar": user.avatar_url or "", }, } async def reset_password_with_sms( db: AsyncSession, *, mobile: str, code: str, new_password: str, ) -> bool: """ 短信验证码重置密码。 流程: 1. 校验验证码 2. 查询用户 3. 更新密码哈希 """ from app.core.security import hash_password settings = get_settings() # 1. 校验验证码(白名单内的手机号跳过校验) if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code): raise ValueError("验证码错误或已过期") # 2. 查询用户 user = await user_crud.get_by_mobile(db, mobile=mobile) if user is None: raise ValueError("用户不存在") # 3. 更新密码 new_hash = hash_password(new_password) await user_crud.update_password(db, user_id=user.id, password_hash=new_hash) return True async def logout(db: AsyncSession, *, user_id: str) -> bool: """ 用户登出。 流程: 1. 删除设备记录 2. 注销 SSE 连接 """ await device_crud.delete_by_user_id(db, user_id=user_id) unregister_sse_connection(user_id) return True async def get_current_user_device( db: AsyncSession, *, user_id: str, ) -> UserDevice | None: """获取当前用户的设备记录""" return await device_crud.get_by_user_id(db, user_id=user_id)