d8c7a6d1ef
Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #69 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
170 lines
7.2 KiB
Python
170 lines
7.2 KiB
Python
"""认证相关 endpoint。
|
|
|
|
路由前缀 `/api/v1/auth`,包含:
|
|
POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT)
|
|
POST /sms/send 发短信验证码(mock 阶段任意 6 位通过)
|
|
POST /sms/login 手机号 + 验证码登录
|
|
POST /refresh 用 refresh_token 换新的 token 对
|
|
GET /me 当前登录用户(Bearer access_token)
|
|
POST /logout 客户端登出(目前服务端无状态,仅返回 ok。后续加 jti 黑名单)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.core import test_account
|
|
from app.core.ratelimit import rate_limit
|
|
from app.core.security import TokenError, decode_token, issue_token_pair
|
|
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
|
from app.integrations.sms import SmsError, send_code, verify_code
|
|
from app.repositories import onboarding as onboarding_repo
|
|
from app.repositories import user as user_repo
|
|
from app.schemas.auth import (
|
|
JverifyLoginRequest,
|
|
LogoutResponse,
|
|
RefreshRequest,
|
|
SmsLoginRequest,
|
|
SmsSendRequest,
|
|
SmsSendResponse,
|
|
TokenPair,
|
|
TokenWithUser,
|
|
UserOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.auth")
|
|
|
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|
|
|
|
|
def _login_response(
|
|
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
|
) -> TokenWithUser:
|
|
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
|
|
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
|
|
tokens = issue_token_pair(user.id)
|
|
return TokenWithUser(
|
|
**tokens,
|
|
user=UserOut.model_validate(user),
|
|
onboarding_completed=onboarding_completed,
|
|
force_onboarding=force_onboarding,
|
|
)
|
|
|
|
|
|
# ===================== 极光一键登录 =====================
|
|
|
|
@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
|
|
def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
|
logger.info(
|
|
"jverify_login operator=%s token_len=%d",
|
|
req.operator or "-",
|
|
len(req.login_token),
|
|
)
|
|
|
|
try:
|
|
phone = verify_and_get_phone(req.login_token)
|
|
except JiguangError as e:
|
|
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
|
|
|
|
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
|
|
if user.status != "active":
|
|
raise HTTPException(status_code=403, detail="account disabled")
|
|
|
|
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
|
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
|
|
return _login_response(user, onboarding_completed=completed)
|
|
|
|
|
|
# ===================== 短信登录 =====================
|
|
|
|
@router.post(
|
|
"/sms/send",
|
|
response_model=SmsSendResponse,
|
|
summary="发送短信验证码",
|
|
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
|
|
)
|
|
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
|
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
|
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
|
if test_account.is_test_account(req.phone):
|
|
logger.info("test_account sms_send short-circuit (不真发)")
|
|
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
|
|
|
try:
|
|
cooldown = send_code(req.phone)
|
|
except SmsError as e:
|
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
|
|
|
from app.core.config import settings # 局部 import 避免循环
|
|
|
|
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
|
|
|
|
|
@router.post(
|
|
"/sms/login",
|
|
response_model=TokenWithUser,
|
|
summary="手机号+验证码登录",
|
|
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
|
)
|
|
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
|
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
|
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
|
if test_account.is_test_account(req.phone):
|
|
if not test_account.try_consume_quota():
|
|
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
|
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
|
if user.status != "active":
|
|
raise HTTPException(status_code=403, detail="account disabled")
|
|
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
|
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
|
|
# 不读/不写 onboarding_completion 表。
|
|
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
|
|
|
if not verify_code(req.phone, req.code):
|
|
raise HTTPException(status_code=400, detail="invalid sms code")
|
|
|
|
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
|
if user.status != "active":
|
|
raise HTTPException(status_code=403, detail="account disabled")
|
|
|
|
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
|
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
|
|
return _login_response(user, onboarding_completed=completed)
|
|
|
|
|
|
# ===================== Refresh =====================
|
|
|
|
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
|
|
def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
|
|
try:
|
|
payload = decode_token(req.refresh_token, expected_type="refresh")
|
|
except TokenError as e:
|
|
raise HTTPException(status_code=401, detail=str(e)) from e
|
|
|
|
user_id = int(payload["sub"])
|
|
user = user_repo.get_user_by_id(db, user_id)
|
|
if user is None or user.status != "active":
|
|
raise HTTPException(status_code=401, detail="user not found or disabled")
|
|
|
|
return TokenPair(**issue_token_pair(user.id))
|
|
|
|
|
|
# ===================== 当前用户 =====================
|
|
|
|
@router.get("/me", response_model=UserOut, summary="获取当前登录用户")
|
|
def me(user: CurrentUser) -> UserOut:
|
|
return UserOut.model_validate(user)
|
|
|
|
|
|
# ===================== Logout =====================
|
|
|
|
@router.post("/logout", response_model=LogoutResponse, summary="登出(占位,客户端清 token)")
|
|
def logout(user: CurrentUser) -> LogoutResponse:
|
|
# 当前服务端无状态,真正的 token 失效靠客户端删 token。
|
|
# TODO: 加 jti 黑名单表后,这里把 access_token 的 jti 写黑名单
|
|
logger.info("logout user_id=%d", user.id)
|
|
return LogoutResponse(ok=True)
|