"""认证相关 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.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) -> TokenWithUser: """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。""" tokens = issue_token_pair(user.id) return TokenWithUser( **tokens, user=UserOut.model_validate(user), onboarding_completed=onboarding_completed, ) # ===================== 极光一键登录 ===================== @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: 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: 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)