Files
video/server/app/services/auth_service.py
T
2026-10-24 00:04:50 +08:00

412 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码"""
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 = settings.refresh_token_ttl # 与 .env 的 REFRESH_TOKEN_TTL 同步
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()