feat: 正式 App 后端登录模块 v0.1.0
引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""API 层共享依赖:DB session、当前登录用户。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import TokenError, decode_token
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger("shagua.deps")
|
||||
|
||||
# auto_error=True → 没传 Authorization header 时直接 403。但我们想 401,
|
||||
# 所以手动 auto_error=False + raise HTTPException
|
||||
_bearer = HTTPBearer(auto_error=False, scheme_name="Bearer")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> User:
|
||||
"""从 Authorization: Bearer <access_token> 解出当前 user。
|
||||
|
||||
失败时统一 401,WWW-Authenticate 头让客户端知道要重新登录。
|
||||
"""
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = decode_token(credentials.credentials, expected_type="access")
|
||||
except TokenError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from e
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
user = db.get(User, user_id)
|
||||
if user is None or user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="user not found or disabled",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""认证相关 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)
|
||||
Reference in New Issue
Block a user