"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码""" import asyncio import logging import random import smtplib import string from datetime import datetime, timedelta from email.mime.text import MIMEText import redis.asyncio as aioredis import httpx from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import biz_config, settings from app.core import errors from app.core.response import BizException from app.core.security import ( create_access_token, create_refresh_token, decode_token, hash_password, verify_password, ) from app.models.incentive import AssetWallet, Referral from app.models.user import User from app.services import config_service, invite_service, membership_service, sensitive_service, wallet_service logger = logging.getLogger(__name__) REFRESH_TTL = 30 * 24 * 3600 DEACTIVATE_FREEZE_DAYS = 7 # ---------- 验证码 ---------- def _code_key(scene: str, channel: str, target: str) -> str: return f"{channel}:code:{scene}:{target}" async def send_code( db: AsyncSession, redis: aioredis.Redis, channel: str, target: str, scene: str, ip: str ) -> dict: """发送验证码(短信/邮箱)。未配置通道时按开发模式返回 dev_code 并记日志。""" # 场景前置校验:register 要求未注册,login/reset 要求已注册 user = await _find_user(db, channel, target) if scene == "register" and user is not None: raise BizException(errors.PARAM_ERROR, "该账号已注册") if scene in ("login", "reset") and user is None: raise BizException(errors.PARAM_ERROR, "该账号未注册") # 限流:同号 60s 1 条 + 同号每天 10 条 + 同 IP 5 条/小时 limit_key = f"{channel}:limit:{target}" if await redis.exists(limit_key): raise BizException(errors.RATE_LIMITED, "发送过于频繁,请 60 秒后再试") day_key = f"{channel}:day:{target}:{datetime.now():%Y%m%d}" if int(await redis.get(day_key) or 0) >= 10: raise BizException(errors.RATE_LIMITED, "今日验证码发送已达上限") ip_key = f"{channel}:iphour:{ip}:{datetime.now():%Y%m%d%H}" if int(await redis.get(ip_key) or 0) >= 5: raise BizException(errors.RATE_LIMITED, "发送过于频繁,请稍后再试") code = "".join(random.choices(string.digits, k=6)) ttl = 300 if channel == "sms" else 600 await redis.set(_code_key(scene, channel, target), code, ex=ttl) await redis.set(limit_key, 1, ex=60) await redis.incr(day_key) await redis.expire(day_key, 48 * 3600) await redis.incr(ip_key) await redis.expire(ip_key, 3600) data = {"expire_seconds": ttl, "retry_after": 60} channel_configured = ( bool(settings.smsbao_username and settings.smsbao_api_key) if channel == "sms" else bool(settings.smtp_host) ) if not channel_configured: logger.info("[dev] %s code for %s (%s): %s", channel, target, scene, code) data["dev_code"] = code elif channel == "sms": await _send_sms_code(target, code) else: await _send_email_code(target, code, scene) return data _SCENES = {"register": "注册", "login": "登录", "reset": "找回密码"} # ---------- 短信(短信宝)---------- SMSBAO_SEND_URL = "https://api.smsbao.com/sms" # 验证码短信正文(签名前缀由 settings.sms_sign_name 提供) _SMS_CONTENT = "您的验证码是{code}。如非本人操作,请忽略本短信" # 接口文本返回码(非 0 均为失败,仅用于日志定位) _SMS_ERRORS = { "30": "密码/ApiKey 错误", "40": "账号不存在", "41": "余额不足", "42": "账号已过期", "43": "IP 地址限制", "50": "内容含有敏感词", "51": "手机号码不正确", "60": "尚未充值,接口不可调用", } async def _send_sms_code(target: str, code: str) -> None: """短信宝发送验证码:返回 '0' 视为提交成功""" content = f"{settings.sms_sign_name}{_SMS_CONTENT.format(code=code)}" try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( SMSBAO_SEND_URL, params={ "u": settings.smsbao_username, "p": settings.smsbao_api_key, "m": target, "c": content, # httpx 自动按 UTF-8 URL Encode }, ) result = resp.text.strip() except Exception: logger.exception("smsbao request failed: %s", target) raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试") if result != "0": logger.error( "smsbao send failed: mobile=%s result=%s(%s)", target, result, _SMS_ERRORS.get(result, "未知错误"), ) raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试") def _smtp_send(target: str, code: str, scene: str) -> None: """SMTP 发送验证码(同步 smtplib,由调用方放线程池)""" msg = MIMEText( f"您的{_SCENES.get(scene, scene)}验证码:{code},10 分钟内有效。" "如非本人操作请忽略本邮件。" ) msg["Subject"] = f"短视频{_SCENES.get(scene, scene)}验证码" msg["From"] = settings.smtp_user msg["To"] = target with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server: server.starttls() server.login(settings.smtp_user, settings.smtp_password) server.send_message(msg) async def _send_email_code(target: str, code: str, scene: str) -> None: """SMTP 异步包装:发送失败视为验证码错误前置(用户需重发)""" try: await asyncio.to_thread(_smtp_send, target, code, scene) except Exception: logger.exception("smtp send failed: %s", target) raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试") async def _verify_code(redis: aioredis.Redis, channel: str, target: str, scene: str, code: str) -> None: key = _code_key(scene, channel, target) saved = await redis.get(key) if not saved or saved != code: raise BizException(errors.CAPTCHA_ERROR) await redis.delete(key) async def _find_user(db: AsyncSession, channel: str, target: str) -> User | None: field = User.phone if channel == "sms" else User.email return (await db.execute(select(User).where(field == target))).scalar_one_or_none() # ---------- Token ---------- async def _issue_tokens(redis: aioredis.Redis, user_id: int) -> dict: """签发双 Token:access 2h + refresh 30d(白名单 Set 支持多端)""" epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0) access, _, _ = create_access_token(user_id, epoch) refresh, refresh_jti, _ = create_refresh_token(user_id, epoch) key = f"auth:refresh:{user_id}" await redis.sadd(key, refresh_jti) # type: ignore[misc] await redis.expire(key, REFRESH_TTL) return {"access_token": access, "refresh_token": refresh, "expires_in": settings.access_token_ttl} async def _user_brief(db: AsyncSession, user: User) -> dict: is_member = await membership_service.is_member(db, user.id) return { "user_id": user.id, "nickname": user.nickname, "avatar_url": user.avatar_url, "is_member": is_member, } # ---------- 注册 ---------- async def register( db: AsyncSession, redis: aioredis.Redis, req, device_id: str | None, ip: str ) -> dict: channel = "sms" if req.type == "phone" else "email" target = req.phone if req.type == "phone" else req.email await _verify_code(redis, channel, target, "register", req.code) # 唯一性校验 dup = await db.execute( select(User.id).where(or_(User.phone == req.phone if req.phone else False, User.email == req.email if req.email else False)) ) if dup.first(): raise BizException(errors.PARAM_ERROR, "手机号/邮箱已注册") # 昵称:默认"用户+随机尾号",过敏感词 nickname = req.nickname or f"用户{random.randint(100000, 999999)}" if await sensitive_service.find_sensitive(db, redis, nickname): raise BizException(errors.SENSITIVE_WORD) # 生成唯一邀请码 invite_code = await _gen_invite_code(db) user = User( phone=req.phone if req.type == "phone" else None, email=req.email if req.type == "email" else None, password_hash=hash_password(req.password), nickname=nickname, register_type=1 if req.type == "phone" else 2, invite_code=invite_code, register_ip=ip, last_login_at=datetime.now(), last_login_ip=ip, ) db.add(user) await db.flush() await wallet_service.get_or_create_wallet(db, user.id) db.add(AssetWallet(user_id=user.id)) # 绑定推荐关系:显式邀请码(口令/手填)优先,否则取首启模糊归因缓存 bind_code = req.invite_code if not bind_code and device_id: bind_code = await invite_service.pop_attribution(redis, device_id) if bind_code: await _bind_referral(db, redis, user, bind_code, device_id, ip) # 设备注册标记(防小号绑定,须在绑定校验之后写入,否则同设备校验永远读到新账号自己) if device_id: await redis.set(f"ref:device:{device_id}", user.id) # 新注册赠送体验会员(后台可配开关与天数,实时生效) trial_granted = None gift_enabled = await config_service.get_config(db, redis, "register_gift_trial.enabled", "1") if str(gift_enabled) == "1": days = await config_service.get_config_int(db, redis, "register_gift_trial.days", 1) order = await membership_service.grant_trial_gift(db, user.id, days) trial_granted = { "plan_code": "trial", "plan_name": "体验会员", "days": days, "end_at": order.end_at.strftime("%Y-%m-%d %H:%M:%S"), } await db.commit() tokens = await _issue_tokens(redis, user.id) return { "user_id": user.id, **tokens, "user": await _user_brief(db, user), "trial_granted": trial_granted, } async def _gen_invite_code(db: AsyncSession) -> str: alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 去除易混淆字符 for _ in range(10): code = "".join(random.choices(alphabet, k=8)) exists = await db.execute(select(User.id).where(User.invite_code == code)) if not exists.first(): return code raise BizException(errors.INTERNAL_ERROR, "邀请码生成失败") async def _bind_referral( db: AsyncSession, redis: aioredis.Redis, invitee: User, invite_code: str, device_id: str | None, ip: str ) -> None: """注册时绑定推荐关系,含风控规则(同设备无效/邀请人单日超限冻结/同IP批量注册标记)""" inviter = (await db.execute(select(User).where(User.invite_code == invite_code))).scalar_one_or_none() if inviter is None or inviter.id == invitee.id: return # 邀请码无效则忽略绑定(不阻断注册) status, risk_flag = 0, "" # 同 device 已存在账号 → 绑定直接无效 if device_id and await redis.exists(f"ref:device:{device_id}"): old = int(await redis.get(f"ref:device:{device_id}") or 0) if old and old != invitee.id: status, risk_flag = 2, "same_device" # 邀请人 24h 新增绑定 >10 → 冻结 risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}" bind_count = await redis.incr(risk_key) await redis.expire(risk_key, 48 * 3600) max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10) if status == 0 and bind_count > max_bind: status, risk_flag = 3, "inviter_burst" # 同 IP 24h 注册 >5 → 标记风险 if status == 0: max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5) cnt = (await db.execute( select(func.count(User.id)).where( User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24) ) )).scalar() or 0 if cnt > max_reg: status, risk_flag = 3, "batch_register" db.add(Referral( inviter_id=inviter.id, invitee_id=invitee.id, invite_code=invite_code, status=status, risk_flag=risk_flag, register_ip=ip, )) # ---------- 登录 ---------- async def login(db: AsyncSession, redis: aioredis.Redis, req, ip: str) -> dict: # 连续失败 5 次锁定 15 分钟(IP 维度) fail_key = f"login:fail:{ip}" if int(await redis.get(fail_key) or 0) >= 5: raise BizException(errors.RATE_LIMITED, "失败次数过多,账号已锁定 15 分钟", http_status=429) channel = "sms" if req.type.startswith("phone") else "email" target = req.phone if channel == "sms" else req.email user = await _find_user(db, channel, target) async def _fail(): await redis.incr(fail_key) await redis.expire(fail_key, 15 * 60) raise BizException(errors.PARAM_ERROR, "账号或密码错误") if user is None: await _fail() if req.type.endswith("code"): try: await _verify_code(redis, channel, target, "login", req.code) except BizException: await _fail() elif not verify_password(req.password, user.password_hash): await _fail() await redis.delete(fail_key) if user.status == 2: raise BizException(errors.FORBIDDEN, "账号已被封禁", http_status=403) # 注销冻结期内登录:返回 1009 + can_revoke(App 弹窗询问;确认后带 revoke=1 重试即撤销注销) if user.deactivate_at is not None: if getattr(req, "revoke", False): user.deactivate_at = None # 冻结期内撤销注销 else: raise BizException( errors.ACCOUNT_DEACTIVATED, "账号已注销", http_status=403, data={"can_revoke": True} ) user.last_login_at = datetime.now() user.last_login_ip = ip await db.commit() tokens = await _issue_tokens(redis, user.id) return {"user_id": user.id, **tokens, "user": await _user_brief(db, user)} # ---------- 刷新 / 登出 / 找回密码 ---------- async def refresh(db: AsyncSession, redis: aioredis.Redis, refresh_token: str) -> dict: """刷新令牌:白名单校验 + 旋转(旧 refresh 立即作废)""" payload = decode_token(refresh_token) if payload.get("type") != "refresh": raise BizException(errors.UNAUTHORIZED, http_status=401) user_id = int(payload["sub"]) jti = payload.get("jti", "") epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0) if int(payload.get("ep", 0)) != epoch: raise BizException(errors.UNAUTHORIZED, http_status=401) key = f"auth:refresh:{user_id}" if not await redis.sismember(key, jti): # type: ignore[misc] raise BizException(errors.UNAUTHORIZED, http_status=401) await redis.srem(key, jti) # type: ignore[misc] # 用户仍须可用 user = await db.get(User, user_id) if user is None or user.status != 1: raise BizException(errors.UNAUTHORIZED, http_status=401) return await _issue_tokens(redis, user_id) async def logout(redis: aioredis.Redis, user_id: int, access_jti: str, access_exp: int) -> None: """登出:access jti 入黑名单 + 删 refresh 白名单""" ttl = max(access_exp - int(datetime.now().timestamp()), 1) await redis.set(f"token:blacklist:{access_jti}", 1, ex=ttl) await redis.delete(f"auth:refresh:{user_id}") async def reset_password(db: AsyncSession, redis: aioredis.Redis, req) -> None: channel = "sms" if req.type == "phone" else "email" target = req.phone if req.type == "phone" else req.email user = await _find_user(db, channel, target) if user is None: raise BizException(errors.PARAM_ERROR, "账号不存在") await _verify_code(redis, channel, target, "reset", req.code) user.password_hash = hash_password(req.new_password) # 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单 await redis.incr(f"auth:epoch:{user.id}") await redis.delete(f"auth:refresh:{user.id}") await db.commit()