b50495bebe
短信(SMS_MOCK 切 mock/real): - integrations/sms.py 重写: real 模式走极光短信 REST /v1/messages 自定义验证码(本服务 secrets 生成 6 位码 + 进程内存 + 本地校验一次性/防爆破), 鉴权复用极光一键登录 JG_APP_KEY/MASTER_SECRET (同一极光应用, 上线只需 SMS_MOCK=false); mock 仍"任意6位通过"不动其余测试 - 防刷四层: 单号冷却 + 单号每日上限 + 单IP rate_limit(/sms/send 10/min、/sms/login 20/min) + 单码失败次数作废; SmsError 带 status_code 映射 429/503/400 - config 增 SMS_SEND_ENDPOINT/SIGN_ID/TEMPLATE_ID/CODE_LENGTH/DAILY_LIMIT/MAX_VERIFY_ATTEMPTS; test_auth 加 real 模式单测; sms.md/后端技术实现/待办账本同步 admin 后台(app/admin/ 独立子应用, uvicorn app.admin.main:admin_app :8771): - 复用主仓 models/repositories/integrations + 同库, 鉴权完全隔离(ADMIN_JWT_SECRET≠JWT_SECRET_KEY + payload typ=admin + bcrypt 密码 + 可选 IP 白名单); 主 app 不 import 本包, admin 崩不影响主进程 - 路由: 登录 / 账号管理(RBAC: super_admin·finance·operator) / 用户列表+360详情+封禁+手动调币 / 钱包流水 / 提现重试对账 / 反馈工单 / 数据大盘; 全写操作落 admin_audit_log(涉钱与业务写同事务) - 涉钱逻辑(调微信/退款/对账)复用 app.repositories.wallet 不重写 - 新增 models/admin.py(AdminUser/AdminAuditLog) + admin_tables 迁移 + create_admin.py + deploy/shaguabijia-admin.service; 依赖加 bcrypt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.1 KiB
Python
142 lines
5.1 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.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 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(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 = user_repo.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="发送短信验证码",
|
|
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")
|
|
|
|
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 = 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)
|