Files
video/server/app/services/auth_service.py
T
2026-10-15 20:36:59 +08:00

332 lines
13 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 logging
import random
import string
from datetime import datetime, timedelta
import redis.asyncio as aioredis
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}
# TODO: 接入阿里云短信 / SMTP 实际发送;未配置通道时按开发模式处理
channel_configured = bool(settings.sms_access_key_id) 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
return data
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()