412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码"""
|
||
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()
|