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:
Pine
2026-08-25 22:47:27 +08:00
parent c2f4c13ab3
commit 32ca580a06
5 changed files with 329 additions and 14 deletions
+185 -4
View File
@@ -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
View File
@@ -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 管理请求/响应
+10 -1
View File
@@ -130,10 +130,19 @@ SMS_CODE_TTL_SECONDS = int(os.environ.get("PINEAGENTS_SMS_CODE_TTL_SECONDS", "30
SMS_RATE_LIMIT = int(os.environ.get("PINEAGENTS_SMS_RATE_LIMIT", "5"))
# ---------------------------------------------------------------------------
# 微信小程序登录(空 appid/secret 时把 code 直通当作 openid,便于无真实 appid 联调
# 微信登录(小程序 code2session + 开放平台网站应用扫码登录
# 未配置 appid/secret 时走「测试直通」:扫码登录把 code 当 openid 返回,便于无真实凭据联调。
# WECHAT_APPID/SECRET 为小程序;WECHAT_OPEN_APPID/SECRET 为开放平台「网站应用」(扫码登录),缺省回退前者。
# ---------------------------------------------------------------------------
WECHAT_APPID = os.environ.get("PINEAGENTS_WECHAT_APPID", "")
WECHAT_SECRET = os.environ.get("PINEAGENTS_WECHAT_SECRET", "")
WECHAT_OPEN_APPID = os.environ.get("PINEAGENTS_WECHAT_OPEN_APPID", "") or WECHAT_APPID
WECHAT_OPEN_SECRET = os.environ.get("PINEAGENTS_WECHAT_OPEN_SECRET", "") or WECHAT_SECRET
# 扫码登录授权后的回跳地址(须在开放平台后台配置为该 URL):默认服务端 /auth/wx-callback。
# 未设置时由前端 request.base_url 推导。
WECHAT_QR_REDIRECT_URI = os.environ.get("PINEAGENTS_WECHAT_QR_REDIRECT_URI", "")
# 扫码登录开关(true 才返回可用的 qr_start;false 时前端隐藏「微信扫码」入口)
AUTH_WECHAT_QR = os.environ.get("PINEAGENTS_WECHAT_QR", "true").strip().lower() in ("true", "1", "yes")
# 本期统一登录仅两模式(账号密码/手机号验证码);微信扫码预留后期,未开放(默认 False)。
AUTH_WECHAT_LOGIN = os.environ.get("PINEAGENTS_WECHAT_LOGIN", "false").strip().lower() in ("true", "1", "yes")
+10 -2
View File
@@ -36,9 +36,17 @@ def _send(phone: str, code: str) -> None:
raise SmsError(f"unsupported sms provider: {config.SMS_PROVIDER}")
# 演示验证码:stub provider 统一用固定码(先跑通流程,生产换真实短信)。
DEMO_CODE = "123456"
def issue(phone: str) -> str:
"""为手机号生成并发送 6 位验证码,返回明文(stub 下发时打印)。"""
code = f"{secrets.randbelow(1_000_000):06d}"
"""为手机号生成并发送 6 位验证码,返回明文(stub 下发时打印)。
演示期 stub provider 统一返回固定码 ``123456``(见配置 SMS_PROVIDER=stub),
便于四端联调;接入真实短信后按 provider 生成随机码。
"""
code = DEMO_CODE if config.SMS_PROVIDER == "stub" else f"{secrets.randbelow(1_000_000):06d}"
with _LOCK:
_STORE[phone] = {
"code": code,
+114 -5
View File
@@ -1,17 +1,27 @@
# -*- coding: utf-8 -*-
"""微信小程序登录服务。
"""微信登录服务(小程序 code2session + 开放平台网站应用扫码登录)
``code2session``小程序 ``wx.login`` 的 code 换 openid。
未配置 ``WECHAT_APPID`` / ``WECHAT_SECRET`` 时走「测试直通」:直接把 code 当
openid 返回,便于无真实微信 appid 的环境联调(生产必须配置真实凭据)。
- ``code2session``小程序 ``wx.login`` 的 code 换 openid。
- ``qr_connect_url``:生成开放平台「网站应用扫码登录」授权链接(前端渲染成二维码)。
- ``oauth_code2openid``:扫码授权回跳的 code 换 openidOAuth2 access_token 端点)。
未配置对应凭据(小程序 WECHAT_APPID/SECRET、扫码 WECHAT_OPEN_APPID/SECRET)时走
「测试直通」:把 code 当 openid 返回,便于无真实凭据联调(生产必须配置真实凭据)。
"""
from __future__ import annotations
import secrets
import threading
import time
from urllib.parse import urlencode
import httpx
from .. import config
_WX_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
_WX_OAUTH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token"
_WX_QR_BASE_URL = "https://open.weixin.qq.com/connect/qrconnect"
class WechatError(Exception):
@@ -19,7 +29,7 @@ class WechatError(Exception):
async def code2session(code: str) -> str:
"""登录 code 换 openid。未配置 appid 时直通:``openid = code``。"""
"""小程序登录 code 换 openid。未配置 appid 时直通:``openid = code``。"""
code = code.strip()
if not config.WECHAT_APPID or not config.WECHAT_SECRET:
return code
@@ -48,3 +58,102 @@ async def code2session(code: str) -> str:
if not openid:
raise WechatError("wechat code2session returned no openid")
return openid
# ---------------------------------------------------------------------------
# 开放平台「网站应用」扫码登录
# ---------------------------------------------------------------------------
def qr_connect_url(state: str, redirect_uri: str) -> str:
"""生成扫码登录授权链接(前端据其渲染二维码)。
扫码后在微信内完成授权 → 微信重定向到 ``redirect_uri?code=&state=``
服务端 ``/auth/wx-callback`` 收到后换 openid 并存到对应 state 会话。
"""
params = {
"appid": config.WECHAT_OPEN_APPID,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "snsapi_login",
"state": state,
}
return f"{_WX_QR_BASE_URL}?{urlencode(params)}#wechat_redirect"
async def oauth_code2openid(code: str) -> str:
"""扫码授权回跳的 code 换 openid。未配置开放平台凭据时直通:``openid = code + '_qr'``。"""
code = code.strip()
if not config.WECHAT_OPEN_APPID or not config.WECHAT_OPEN_SECRET:
return f"{code}_qr"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
_WX_OAUTH_TOKEN_URL,
params={
"appid": config.WECHAT_OPEN_APPID,
"secret": config.WECHAT_OPEN_SECRET,
"code": code,
"grant_type": "authorization_code",
},
)
data = resp.json()
except Exception as exc: # noqa: BLE001
raise WechatError(f"wechat oauth token request failed: {exc}") from exc
errcode = data.get("errcode", 0) or data.get("errCode", 0)
if errcode:
raise WechatError(f"wechat oauth error {errcode}: {data.get('errmsg', '')}")
openid = data.get("openid")
if not openid:
raise WechatError("wechat oauth returned no openid")
return openid
class WxQrSessionStore:
"""扫码登录会话:scene → {status, token, profile, expires_at}(内存 + TTL)。
- ``start``(scene)pending。
- ``complete``(scene, token, profile)done(回调存登录结果)。
- ``poll``(scene)done 时返回并消费(一次性),否则 pending/expired。
进程重启即失效——生产可在 Redis 持久化(见架构 §8.4)。
"""
_TTL = 120 # 会话有效期(秒)
def __init__(self) -> None:
self._data: dict[str, dict] = {}
self._lock = threading.Lock()
def start(self, scene: str) -> None:
with self._lock:
self._data[scene] = {
"status": "pending", "token": "", "profile": None,
"expires_at": time.monotonic() + self._TTL,
}
def complete(self, scene: str, token: str, profile: dict) -> None:
with self._lock:
if scene in self._data:
self._data[scene].update(
{"status": "done", "token": token, "profile": profile},
)
def poll(self, scene: str) -> dict:
with self._lock:
rec = self._data.get(scene)
if not rec:
return {"status": "expired"}
if time.monotonic() > rec["expires_at"]:
self._data.pop(scene, None)
return {"status": "expired"}
if rec["status"] == "done":
# 消费一次(防重复取),但保留短暂窗口
rec["status"] = "consumed"
return {"status": "done", "token": rec["token"], "profile": rec["profile"]}
return {"status": rec["status"], "token": "", "profile": None}
def new_scene() -> str:
return secrets.token_urlsafe(24)