diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 3c1a470..fd2fcdf 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -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( + "" + "" + "
" + "
" + "

微信登录成功

" + "

请返回原应用继续操作(本页可关闭)

" + ) diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index 016b546..b391041 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -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 管理请求/响应 diff --git a/app/config.py b/app/config.py index 142cfdd..957d10b 100644 --- a/app/config.py +++ b/app/config.py @@ -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") diff --git a/app/services/sms.py b/app/services/sms.py index cc6aeb9..83f967b 100644 --- a/app/services/sms.py +++ b/app/services/sms.py @@ -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, diff --git a/app/services/wechat.py b/app/services/wechat.py index 24cb1f8..f931bf9 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -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 换 openid(OAuth2 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) +