feat(auth): 统一四种登录 —— 短信演示码123456 + 微信扫码OAuth + 小程序扫码
- sms stub 固定演示码123456 - 微信扫码(开放平台标准OAuth): /auth/wx-qr/start|wx-callback(GET/POST)|wx-qr/poll - 小程序扫码: /auth/mp-qr/start|poll|confirm - /auth/status 返回四 login_modes;WECHAT_OPEN_APPID/SECRET、AUTH_WECHAT_QR、WECHAT_QR_REDIRECT_URI 配置
This commit is contained in:
+185
-4
@@ -12,6 +12,7 @@ import re
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ... import config
|
||||
from ..dependencies import get_current_user, get_db, extract_bearer_token
|
||||
@@ -31,12 +32,23 @@ from ..schemas.auth import (
|
||||
VerifyResponse,
|
||||
WxLoginRequest,
|
||||
WxPhoneRequest,
|
||||
MpQrConfirmRequest,
|
||||
)
|
||||
from ...infrastructure.repositories import Database
|
||||
from ...services import sms, wechat
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# 扫码登录会话(内存 + TTL;生产可换 Redis)
|
||||
wx_qr_store = wechat.WxQrSessionStore()
|
||||
|
||||
|
||||
def _wx_redirect_uri(request: Request) -> str:
|
||||
"""扫码授权回跳地址:显式配置优先,否则按请求 base_url 推导。"""
|
||||
if config.WECHAT_QR_REDIRECT_URI:
|
||||
return config.WECHAT_QR_REDIRECT_URI
|
||||
return str(request.base_url).rstrip("/") + "/auth/wx-callback"
|
||||
|
||||
def _parse_topics(raw) -> list:
|
||||
"""topics(JSON 文本/list) → list;失败兜底 []。"""
|
||||
if not raw:
|
||||
@@ -282,12 +294,18 @@ async def register(req: RegisterRequest, request: Request, db: Database = Depend
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse, summary="认证状态")
|
||||
async def auth_status(db: Database = Depends(get_db)):
|
||||
"""前端登录页据此判断是否展示登录表单。本期登录模式:账号密码 + 手机号验证码;微信扫码预留(未开放)。"""
|
||||
"""前端登录页据此展示登录方式。四种:账号密码 / 短信验证码 / 微信扫码 / 小程序扫码。"""
|
||||
modes = ["password", "phone"]
|
||||
if config.AUTH_WECHAT_QR:
|
||||
modes.append("wechat") # 微信开放平台扫码(OAuth)
|
||||
if config.AUTH_WECHAT_LOGIN:
|
||||
modes.append("miniprogram") # 小程序扫码(wx.login → code2session)
|
||||
return AuthStatusResponse(
|
||||
enabled=config.AUTH_ENABLED,
|
||||
has_users=await db.users.has_users(),
|
||||
login_modes=["password", "phone"] + (["wechat"] if config.AUTH_WECHAT_LOGIN else []),
|
||||
login_modes=modes,
|
||||
wechat_login=config.AUTH_WECHAT_LOGIN,
|
||||
wx_qr=config.AUTH_WECHAT_QR,
|
||||
)
|
||||
|
||||
|
||||
@@ -465,7 +483,8 @@ async def phone_login(req: PhoneLoginRequest, request: Request, db: Database = D
|
||||
except sms.SmsError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
|
||||
user = await db.users.get_by_username(req.phone)
|
||||
# 手机号唯一:既按「登录名=手机号」查,也按「已绑定手机」查(微信等身份绑了手机的用户)。
|
||||
user = await db.users.find_by_phone(req.phone) or await db.users.get_by_username(req.phone)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
req.phone, password=secrets.token_hex(16),
|
||||
@@ -537,7 +556,7 @@ async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
|
||||
user = await db.users.get_by_wx_openid(req.openid)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="微信账号不存在,请先 wx-login")
|
||||
bound = await db.users.get_by_username(req.phone)
|
||||
bound = await db.users.find_by_phone(req.phone) or await db.users.get_by_username(req.phone)
|
||||
if bound is not None and bound["id"] != user["id"]:
|
||||
raise HTTPException(status_code=409, detail="该手机号已绑定其它账号")
|
||||
|
||||
@@ -547,3 +566,165 @@ async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
|
||||
detail=f"wx bind phone {req.phone}", user_id=user["id"],
|
||||
)
|
||||
return {**await db.users.to_profile(fresh), "phone": fresh.get("phone", "")}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 微信开放平台扫码登录(标准 OAuth:qr_start → 扫码 → wx-callback → poll)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def _find_or_create_wx_user(
|
||||
openid: str, request: Request, db: Database,
|
||||
) -> dict:
|
||||
"""按扫码登录的 openid(存 wx_openid)查找/创建用户(幂等,唯一)。"""
|
||||
user = await db.users.get_by_wx_openid(openid)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
f"wx_{openid[:24]}", password=secrets.token_hex(16),
|
||||
wx_openid=openid, nickname="微信用户",
|
||||
role="opc_member",
|
||||
source=_client_source(request, "", "wx_openid"), auth_type="wx_openid",
|
||||
register_ip=_client_ip(request),
|
||||
)
|
||||
if user.get("status") != "active":
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/wx-qr/start", summary="发起微信扫码登录")
|
||||
async def wx_qr_start(request: Request, db: Database = Depends(get_db)):
|
||||
"""生成扫码登录会话:返回 scene + 授权链接(渲染二维码)。
|
||||
|
||||
- 前端把 ``qr_url`` 渲染成二维码展示;扫码后在微信内授权。
|
||||
- 授权回跳 ``/auth/wx-callback``,前端轮询 ``/auth/wx-qr/poll?scene=`` 取令牌。
|
||||
"""
|
||||
if not config.AUTH_WECHAT_QR:
|
||||
raise HTTPException(status_code=503, detail="微信扫码登录未开启")
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
scene = wechat.new_scene()
|
||||
redirect_uri = _wx_redirect_uri(request)
|
||||
qr_url = wechat.qr_connect_url(scene, redirect_uri)
|
||||
wx_qr_store.start(scene)
|
||||
return {
|
||||
"scene": scene,
|
||||
"qr_url": qr_url,
|
||||
"redirect_uri": redirect_uri,
|
||||
"stub": not (config.WECHAT_OPEN_APPID and config.WECHAT_OPEN_SECRET),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/wx-callback", summary="微信扫码授权回跳")
|
||||
async def wx_callback_get(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""微信扫码授权回跳:换 openid → 建/复用账号 → 签发令牌,存到 scene 会话。"""
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="缺少 code")
|
||||
try:
|
||||
openid = await wechat.oauth_code2openid(code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
user = await _find_or_create_wx_user(openid, request, db)
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
resp = await _login_response(db, user, identity)
|
||||
wx_qr_store.complete(state or openid, resp.token, resp.model_dump())
|
||||
return _wx_success_html()
|
||||
|
||||
|
||||
@router.post("/wx-callback", summary="微信扫码授权回跳(SPA 直交)")
|
||||
async def wx_callback_post(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
"""SPA 自行拿到 code 后 POST 到此:直接返回登录响应(不走轮询)。"""
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="缺少 code")
|
||||
try:
|
||||
openid = await wechat.oauth_code2openid(code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
user = await _find_or_create_wx_user(openid, request, db)
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
return await _login_response(db, user, identity)
|
||||
|
||||
|
||||
@router.get("/wx-qr/poll", summary="轮询扫码登录状态")
|
||||
async def wx_qr_poll(scene: str):
|
||||
"""轮询扫码登录会话:done 时返回令牌+资料(一次性消费)。"""
|
||||
result = wx_qr_store.poll(scene)
|
||||
if result["status"] == "done":
|
||||
return {"status": "done", "token": result["token"], "profile": result["profile"]}
|
||||
return {"status": result["status"]}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 小程序扫码登录(web/桌面端展示小程序二维码 → 小程序内确认 → 轮询取令牌)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get("/mp-qr/start", summary="发起小程序扫码登录")
|
||||
async def mp_qr_start(request: Request, db: Database = Depends(get_db)):
|
||||
"""生成小程序扫码登录会话,返回 scene + 供渲染的链接。"""
|
||||
if not config.AUTH_WECHAT_LOGIN:
|
||||
raise HTTPException(status_code=503, detail="小程序登录未开启")
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
scene = wechat.new_scene()
|
||||
wx_qr_store.start(scene)
|
||||
# 小程序码需真实 wxacode;此处给可扫描的网页兜底链接 + 场景参数,
|
||||
# 真实小程序可通过 /auth/mp-qr/confirm 回传登录结果。前端据 qr_url 渲染二维码。
|
||||
qr_url = f"https://opc.pinesound.cn/mp/login?scene={scene}"
|
||||
return {"scene": scene, "qr_url": qr_url, "mp_enabled": True}
|
||||
|
||||
|
||||
@router.get("/mp-qr/poll", summary="轮询小程序扫码登录状态")
|
||||
async def mp_qr_poll(scene: str):
|
||||
result = wx_qr_store.poll(scene)
|
||||
if result["status"] == "done":
|
||||
return {"status": "done", "token": result["token"], "profile": result["profile"]}
|
||||
return {"status": result["status"]}
|
||||
|
||||
|
||||
@router.post("/mp-qr/confirm", summary="小程序内确认扫码登录")
|
||||
async def mp_qr_confirm(request: Request, req: MpQrConfirmRequest, db: Database = Depends(get_db)):
|
||||
"""小程序内 wx.login 后调用:以 scene 关联 web 端会话,回写已登录令牌。"""
|
||||
if not config.AUTH_ENABLED:
|
||||
raise HTTPException(status_code=403, detail="认证未开启")
|
||||
try:
|
||||
openid = await wechat.code2session(req.code)
|
||||
except wechat.WechatError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
source = _client_source(request, "mini_program", "wx_openid")
|
||||
user = await db.users.get_by_wx_mini_openid(openid)
|
||||
if user is None:
|
||||
user = await db.users.create(
|
||||
f"wx_{openid[:24]}", password=secrets.token_hex(16),
|
||||
wx_mini_openid=openid, nickname="微信小程序用户",
|
||||
role="opc_member", source="mini_program", auth_type="wx_openid",
|
||||
register_ip=_client_ip(request),
|
||||
)
|
||||
if user.get("status") != "active":
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
identity = await _ensure_opc_identity(db, user["id"])
|
||||
resp = await _login_response(db, user, identity)
|
||||
wx_qr_store.complete(req.scene, resp.token, resp.model_dump())
|
||||
return {"ok": True, "username": user["username"]}
|
||||
|
||||
|
||||
def _wx_success_html() -> HTMLResponse:
|
||||
"""授权成功后给微信浏览器的简单页面(扫码端展示,闭环)。"""
|
||||
return HTMLResponse(
|
||||
"<!doctype html><html lang=zh><meta charset=utf-8>"
|
||||
"<meta name=viewport content='width=device-width'>"
|
||||
"<body style='display:flex;align-items:center;justify-content:center;"
|
||||
"height:100vh;font-family:sans-serif'><div style='text-align:center'>"
|
||||
"<div style='font-size:48px'>✅</div>"
|
||||
"<h3>微信登录成功</h3>"
|
||||
"<p>请返回原应用继续操作(本页可关闭)</p></div></body></html>"
|
||||
)
|
||||
|
||||
+10
-2
@@ -58,8 +58,9 @@ class RevokeTokenRequest(BaseModel):
|
||||
class AuthStatusResponse(BaseModel):
|
||||
enabled: bool
|
||||
has_users: bool
|
||||
login_modes: list[str] = ["password", "phone"] # 本期:账号密码 + 手机号验证码;微信扫码预留
|
||||
wechat_login: bool = False # 微信扫码(预留,本期未开放)
|
||||
login_modes: list[str] = ["password", "phone"] # password/phone/wechat/miniprogram(四种)
|
||||
wechat_login: bool = False # 小程序扫码(code2session)
|
||||
wx_qr: bool = False # 微信开放平台扫码(OAuth)
|
||||
|
||||
class VerifyResponse(BaseModel):
|
||||
valid: bool
|
||||
@@ -139,5 +140,12 @@ class WxPhoneRequest(BaseModel):
|
||||
code: str = Field(description="手机号接收到的验证码")
|
||||
|
||||
|
||||
class MpQrConfirmRequest(BaseModel):
|
||||
"""小程序扫码登录确认(小程序内 wx.login 后调用)。"""
|
||||
|
||||
scene: str = Field(description="web/桌面端 /auth/mp-qr/start 返回的场景")
|
||||
code: str = Field(description="wx.login 返回的 code")
|
||||
|
||||
|
||||
# RBAC 管理请求/响应
|
||||
|
||||
|
||||
Reference in New Issue
Block a user