1aafc28621
引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
131 lines
4.8 KiB
Python
131 lines
4.8 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, HTTPException, status
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.core.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
|
from app.core.security import TokenError, decode_token, issue_token_pair
|
|
from app.core.sms import SmsError, send_code, verify_code
|
|
from app.crud import user as crud_user
|
|
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(db, user) -> TokenWithUser:
|
|
"""登录类接口共用的响应组装。"""
|
|
tokens = issue_token_pair(user.id)
|
|
return TokenWithUser(
|
|
**tokens,
|
|
user=UserOut.model_validate(user),
|
|
)
|
|
|
|
|
|
# ===================== 极光一键登录 =====================
|
|
|
|
@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 = crud_user.upsert_user_for_login(db, phone=phone, register_channel="jverify")
|
|
if user.status != "active":
|
|
raise HTTPException(status_code=403, detail="account disabled")
|
|
|
|
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
|
|
return _login_response(db, user)
|
|
|
|
|
|
# ===================== 短信登录 =====================
|
|
|
|
@router.post("/sms/send", response_model=SmsSendResponse, summary="发送短信验证码 (mock)")
|
|
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
|
try:
|
|
cooldown = send_code(req.phone)
|
|
except SmsError as e:
|
|
raise HTTPException(status_code=429, 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="手机号+验证码登录")
|
|
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 = crud_user.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 ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
|
return _login_response(db, user)
|
|
|
|
|
|
# ===================== 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 = crud_user.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)
|