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( + "" + "" + "
请返回原应用继续操作(本页可关闭)