Files
server-core/app/api/routers/auth.py
T
Pine 0cdcdeed26 feat(auth): 关闭所有微信登录(开放平台扫码),只保留小程序登录
- AUTH_WECHAT_QR 默认 false(禁用微信扫码OAuth); AUTH_WECHAT_LOGIN 默认 true(小程序登录开启)
- mp-qr/start 改挂 AUTH_WECHAT_LOGIN(小程序); /auth/status login_modes 不含 wechat
2026-08-26 12:25:53 +08:00

821 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""认证路由:登录 / 注册 / 状态 / 校验 / 资料 / 令牌管理。
这是供 PineAgents 主后端 ``src/pineagents/app/routers/auth.py`` 转发调用的
"演示 FastAPI"DEMO_API_BASE_URL,默认 http://127.0.0.1:8090)。
路由前缀 ``/auth``,字段与主后端转发模型完全一致,保证透传不丢字段。
"""
from __future__ import annotations
import base64
import json
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
from ..schemas.auth import (
AuthStatusResponse,
IdentityInfo,
LoginRequest,
LoginResponse,
PhoneLoginRequest,
ProfileResponse,
RegisterRequest,
RevokeTokenRequest,
SelectIdentityRequest,
SendCodeRequest,
SendCodeResponse,
UpdateProfileRequest,
VerifyResponse,
WxLoginRequest,
WxPhoneRequest,
MpQrConfirmRequest,
BindPhoneRequest,
UnbindRequest,
)
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:
return []
if isinstance(raw, list):
return raw
if isinstance(raw, str):
try:
v = json.loads(raw)
return v if isinstance(v, list) else []
except (TypeError, ValueError):
return []
return []
# 用户来源(X-Client 头,回退 body.source;未指认则按 auth_type 推断)
_SOURCE_BY_CLIENT = {
"miniprogram": "mini_program", "mini_program": "mini_program", "mp": "mini_program",
"web": "web", "browser": "web",
"desktop": "desktop", "app": "desktop", "tauri": "desktop",
"park": "park", "admin": "admin", "enterprise": "enterprise",
"carrier": "carrier", "government": "government", "provider": "provider", "operator": "operator",
}
def _client_source(request: Request, body_source: str = "", auth_type: str = "") -> str:
"""确定用户来源:X-Client 头 > body.source > 按 auth_type 推断。"""
client = (request.headers.get("X-Client", "") or "").strip().lower()
if client in _SOURCE_BY_CLIENT:
return _SOURCE_BY_CLIENT[client]
if body_source in _SOURCE_BY_CLIENT.values():
return body_source
if body_source:
return body_source
if auth_type == "wx_openid":
return "wx"
if auth_type == "phone":
return "phone"
return "web"
def _client_ip(request: Request) -> str:
xff = request.headers.get("X-Forwarded-For", "")
if xff:
return xff.split(",")[0].strip()
return request.client.host if (request.client and request.client.address) else ""
# 账号统一使用手机号作为登录账号(11 位,1 开头)
_PHONE_RE = re.compile(r"^1\d{10}$")
def _is_phone(account: str) -> bool:
return bool(_PHONE_RE.match(account.strip()))
# 账户管理页可编辑的资料字段(username 作为登录名走 update_credentials,单独处理)
_PROFILE_FIELDS = (
"nickname",
"account",
"room",
"avatar",
"company_avatar",
"email",
"gender",
"birthday",
)
def _identity_summaries(identities: list[dict]) -> list[IdentityInfo]:
"""把 user_identities 记录裁剪成对外身份摘要。"""
return [
IdentityInfo(
id=i["id"], port=i["port"], role=i["role"], sub_role=i.get("sub_role"),
name=i.get("name", ""), org_id=i.get("org_id"), region_id=i.get("region_id"),
org_name=i.get("org_name"), region_name=i.get("region_name"),
status=i.get("status", "active"),
)
for i in identities
]
async def _issue_token(
db: Database,
user: dict,
expires_in: int | None,
identity: dict | None = None,
) -> dict:
"""为指定用户签发 JWT(可按端口身份作用域),返回 token 记录。
``identity`` 非空时按该身份解析 role/sub_role/org/region 与权限;
否则回退到 ``users.role``(单角色/中性令牌)。
"""
role = (identity or user).get("role", "opc_member")
sub_role = (identity or user).get("sub_role")
org_id = (identity or user).get("org_id")
region_id = (identity or user).get("region_id")
identity_id = identity.get("id") if identity else None
perms = await db.roles.permissions_for(role, sub_role)
scope_ids = await db.regions.visible_region_ids(region_id)
scope_level = await db.regions.level(region_id)
token_user = dict(user)
token_user.update(role=role, sub_role=sub_role, org_id=org_id, region_id=region_id)
await db.users.mark_login(user["id"]) # 记录最近登录时间(每次签发令牌=一次活动)
return await db.tokens.create(
token_user,
permissions=perms,
scope_region_ids=scope_ids,
scope_level=scope_level,
expiry_seconds=config.resolve_token_expiry(expires_in),
identity_id=identity_id,
)
async def _profile_for(
user: dict,
identity: dict | None,
db: Database,
) -> dict:
"""按身份(或回退用户单角色)构建对外资料字典。"""
role = (identity or user).get("role", "opc_member")
sub_role = (identity or user).get("sub_role")
org_id = (identity or user).get("org_id")
region_id = (identity or user).get("region_id")
profile = {f: user.get(f, "") for f in _PROFILE_FIELDS}
profile.update(username=user.get("username", ""))
profile["name"] = user.get("nickname") or user.get("username", "")
profile["phone"] = user.get("phone", "")
profile["phoneBound"] = bool(user.get("phone", ""))
profile["wxBound"] = bool(user.get("wx_openid", ""))
profile["wxMiniBound"] = bool(user.get("wx_mini_openid", ""))
profile["status"] = user.get("opc_status", "")
profile["topics"] = _parse_topics(user.get("topics", ""))
profile["source"] = user.get("source", "")
profile["auth_type"] = user.get("auth_type", "")
profile["company"] = user.get("company", "")
profile["compute_provisioned"] = bool(user.get("compute_provisioned", False))
profile["compute_quota"] = user.get("compute_quota", 0)
profile["compute_used_quota"] = user.get("compute_used_quota", 0)
profile["certification_status"] = user.get("certification_status", "uncertified")
profile["affiliation"] = user.get("affiliation", "independent")
profile["park_id"] = user.get("park_id", "")
profile["park_name"] = user.get("park_name", "")
profile["account_type"] = user.get("account_type", "opc_default")
profile["role"] = role
profile["sub_role"] = sub_role
profile["org_id"] = org_id
profile["region_id"] = region_id
profile["permissions"] = await db.roles.permissions_for(role, sub_role)
profile["scope_region_ids"] = await db.regions.visible_region_ids(region_id)
if identity:
profile["identity_id"] = identity["id"]
profile["port"] = identity["port"]
profile["identity_name"] = identity.get("name", "")
return profile
@router.post("/login", response_model=LoginResponse, summary="登录")
async def login(req: LoginRequest, db: Database = Depends(get_db)):
"""校验用户名密码,返回账号绑定的全部端口身份 + 令牌。
- 仅一个身份:令牌直接绑定该身份(自动登录)。
- 多个身份:签发中性账号令牌,前端展示身份选择,经
``/auth/select-identity`` 切换到指定身份后进入对应端口。
"""
user = await db.users.get_by_username(req.username)
if user is None or not await db.users.verify_password(user, req.password):
raise HTTPException(status_code=401, detail="Invalid username or password")
if user.get("status") != "active":
raise HTTPException(status_code=403, detail="Account is disabled")
identities = await db.identities.list_for_user(user["id"], active_only=True)
identity = identities[0] if len(identities) == 1 else None
token_record = await _issue_token(db, user, req.expires_in, identity=identity)
profile = await _profile_for(user, identity, db)
await db.audit.add(
action="login", resource="auth", resource_id=user["id"],
detail=f"login {user['username']} ({len(identities)} identities)",
user_id=user["id"],
)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries(identities),
**profile,
)
@router.post("/select-identity", response_model=LoginResponse, summary="切换端口身份")
async def select_identity(
req: SelectIdentityRequest,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""把当前令牌切换到指定端口身份(该身份须属于当前账号且为启用状态)。"""
ident = await db.identities.get_for_user(req.identity_id, user["id"])
if ident is None or ident.get("status") != "active":
raise HTTPException(status_code=404, detail="Identity not found or disabled")
token_record = await _issue_token(db, user, None, identity=ident)
profile = await _profile_for(user, ident, db)
identities = await db.identities.list_for_user(user["id"], active_only=True)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries(identities),
**profile,
)
@router.post("/register", response_model=LoginResponse, summary="注册")
async def register(req: RegisterRequest, request: Request, db: Database = Depends(get_db)):
"""注册唯一账户(演示端已存在 pine,故返回 403)。"""
if not _is_phone(req.username):
raise HTTPException(
status_code=400,
detail="账号需为 11 位手机号(如 138xxxx1234",
)
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="Authentication is not enabled")
if await db.users.has_users():
raise HTTPException(status_code=403, detail="User already registered")
if not req.username.strip() or not req.password.strip():
raise HTTPException(
status_code=400,
detail="Username and password are required",
)
user = await db.users.create(
req.username, req.password,
source=_client_source(request, getattr(req, "source", ""), "password"),
auth_type="password", register_ip=_client_ip(request),
)
identity = await db.identities.create(
user["id"], port="opc", role="opc_member",
sub_role="independent", name="独立OPC",
)
token_record = await _issue_token(db, user, req.expires_in, identity=identity)
profile = await _profile_for(user, identity, db)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries([identity]),
**profile,
)
@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=modes,
wechat_login=config.AUTH_WECHAT_LOGIN,
wx_qr=config.AUTH_WECHAT_QR,
env=config.APP_ENV,
wxacode_env=config.WECHAT_WXACODE_ENV,
)
@router.get("/verify", response_model=VerifyResponse, summary="校验令牌")
async def verify(request: Request, db: Database = Depends(get_db)):
"""校验调用方 Bearer 令牌;无效令牌由依赖层直接返回 401。"""
if not config.AUTH_ENABLED:
return VerifyResponse(valid=True, username="")
user = await get_current_user(request, db)
return VerifyResponse(valid=True, username=user["username"])
@router.get("/me", response_model=dict, summary="当前用户资料")
async def me(
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""返回当前登录用户的完整资料(含角色/组织/区域/权限/数据范围)。"""
return await db.users.to_profile(user)
@router.post("/update-profile", response_model=ProfileResponse, summary="更新资料/凭据")
async def update_profile(
req: UpdateProfileRequest,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""更新演示资料与/或用户名、密码,返回更新后的完整资料。
- 修改用户名/密码时必须提供正确的 ``current_password``
- 修改凭据后吊销该用户其余会话并签发新令牌随响应返回;
- 仅改资料字段时不重签令牌。
"""
payload = req.model_dump(exclude_none=True)
user_id = user["id"]
new_username = payload.get("new_username")
new_password = payload.get("new_password")
if new_username is not None and not new_username.strip():
raise HTTPException(status_code=400, detail="Username cannot be empty")
if new_password is not None and not new_password.strip():
raise HTTPException(status_code=400, detail="Password cannot be empty")
profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload}
# 小程序契约字段 → users 列映射(name→nicknamestatus→opc_statustopics→JSON文本)
if "name" in payload:
profile_updates["nickname"] = payload["name"]
if "status" in payload:
profile_updates["opc_status"] = payload["status"]
if "topics" in payload:
t = payload["topics"]
profile_updates["topics"] = json.dumps(t, ensure_ascii=False) if isinstance(t, list) else (t or "")
changing_credentials = new_username is not None or new_password is not None
if not profile_updates and not changing_credentials:
raise HTTPException(status_code=400, detail="Nothing to update")
if changing_credentials and not await db.users.verify_password(user, req.current_password):
raise HTTPException(status_code=401, detail="Current password is incorrect")
if profile_updates:
await db.users.update_profile(user_id, profile_updates)
issued_token = ""
if changing_credentials:
await db.users.update_credentials(user_id, new_username, new_password)
await db.tokens.revoke_all(user_id)
fresh_user = await db.users.get_by_id(user_id)
token_record = await _issue_token(db, fresh_user, req.expires_in)
issued_token = token_record["token"]
fresh_user = await db.users.get_by_id(user_id)
fresh_user["permissions"] = await db.roles.permissions_for(
fresh_user.get("role", "opc_member"), fresh_user.get("sub_role"),
)
fresh_user["scope_region_ids"] = await db.regions.visible_region_ids(fresh_user.get("region_id"))
return ProfileResponse(
token=issued_token,
**await db.users.to_profile(fresh_user),
)
@router.post("/revoke-token", summary="吊销单个令牌")
async def revoke_single_token(
req: RevokeTokenRequest,
request: Request,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""吊销指定令牌(省略则吊销当前令牌)。"""
caller_token = extract_bearer_token(request)
token_to_revoke = req.token or caller_token
is_current = token_to_revoke == caller_token
if not await db.tokens.revoke(token_to_revoke):
raise HTTPException(status_code=500, detail="Failed to revoke token")
message = (
"Current token has been revoked. Please login again."
if is_current
else "Specified token has been revoked."
)
return {
"message": message,
"revoked": True,
"revoked_current_token": is_current,
}
@router.post("/revoke-all-tokens", summary="吊销全部令牌")
async def revoke_all_sessions(
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""吊销所有令牌,所有会话需重新登录。"""
await db.tokens.revoke_all()
return {
"message": "All tokens have been revoked. Please login again.",
"revoked": True,
}
# ===========================================================================
# 统一登录(手机验证码 / 微信)—— 六端登录归一
# ===========================================================================
async def _ensure_opc_identity(db: Database, user_id: str) -> dict:
"""确保账号拥有 opc_member 身份(登录即建;重复登录复用已有)。"""
for ident in await db.identities.list_for_user(user_id, active_only=True):
if ident["port"] == "opc" and ident["role"] == "opc_member":
return ident
return await db.identities.create(
user_id, port="opc", role="opc_member",
sub_role="independent", name="独立OPC",
)
async def _login_response(
db: Database, user: dict, identity: dict, identities: list[dict] | None = None,
) -> LoginResponse:
"""以指定身份签发令牌并装配登录响应(与密码登录同构)。"""
token_record = await _issue_token(db, user, None, identity=identity)
profile = await _profile_for(user, identity, db)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries(identities or [identity]),
**profile,
)
@router.post("/send-code", response_model=SendCodeResponse, summary="发送短信验证码")
async def send_code(req: SendCodeRequest, db: Database = Depends(get_db)):
"""为手机号发送登录验证码。stub provider 仅在服务日志打印。"""
if not _is_phone(req.phone):
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
code = sms.issue(req.phone)
return SendCodeResponse(
sent=True,
stub_code=code if config.SMS_PROVIDER == "stub" else None,
)
@router.post("/phone-login", response_model=LoginResponse, summary="手机号验证码登录")
async def phone_login(req: PhoneLoginRequest, request: Request, db: Database = Depends(get_db)):
"""手机号 + 验证码登录:无账号则注册(建 opc_member 身份)。"""
if not _is_phone(req.phone):
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
try:
sms.verify(req.phone, req.code)
except sms.SmsError as exc:
raise HTTPException(status_code=401, detail=str(exc))
# 手机号唯一:既按「登录名=手机号」查,也按「已绑定手机」查(微信等身份绑了手机的用户)。
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),
phone=req.phone, role="opc_member",
source=_client_source(request, getattr(req, "source", ""), "phone"),
auth_type="phone", register_ip=_client_ip(request),
)
if user.get("status") != "active":
raise HTTPException(status_code=403, detail="账号已禁用")
identity = await _ensure_opc_identity(db, user["id"])
await db.audit.add(
action="login", resource="auth", resource_id=user["id"],
detail=f"phone-login {req.phone}", user_id=user["id"],
)
return await _login_response(db, user, identity)
@router.post("/wx-login", response_model=LoginResponse, summary="微信扫码登录(预留)")
async def wx_login(req: WxLoginRequest, request: Request, db: Database = Depends(get_db)):
"""微信扫码登录:本期预留未开放。开启需 PINEAGENTS_WECHAT_LOGIN=true 且配置 appid/secret。"""
if not config.AUTH_WECHAT_LOGIN:
raise HTTPException(status_code=503, detail="微信登录预留中,本期未开放")
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, getattr(req, "source", ""), "wx_openid")
is_mini = source == "mini_program"
# 小程序登录存 wx_mini_openid(与网页/公众号 openid 不同);否则存 wx_openid
if is_mini:
user = await db.users.get_by_wx_mini_openid(openid) or await db.users.get_by_wx_openid(openid)
else:
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 if not is_mini else "",
wx_mini_openid=openid if is_mini else "",
nickname=str(getattr(req, "nickName", "") or ""),
avatar=str(getattr(req, "avatarUrl", "") or ""),
role="opc_member", source=source, 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"])
await db.audit.add(
action="login", resource="auth", resource_id=user["id"],
detail=f"wx-login {openid[:16]}", user_id=user["id"],
)
return await _login_response(db, user, identity)
@router.post("/wx-phone", summary="微信补绑手机号")
async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
"""给已登录的微信账号补绑手机号。若该手机号被其它账号占用,则按手机号合并(不新增账号)。"""
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
try:
sms.verify(req.phone, req.code)
except sms.SmsError as exc:
raise HTTPException(status_code=401, detail=str(exc))
user = await db.users.get_by_wx_openid(req.openid)
if user is None:
raise HTTPException(status_code=404, detail="微信账号不存在,请先 wx-login")
user = await _bind_phone_merge(db, user, req.phone)
await db.audit.add(
action="bind-phone", resource="auth", resource_id=user["id"],
detail=f"wx bind phone {req.phone}", user_id=user["id"],
)
return await _login_response(db, user, await _ensure_opc_identity(db, user["id"]))
# ===========================================================================
# 统一账号:绑定/解绑登录方式 + 按手机号合并(同一账号,绝不新增)
# ===========================================================================
def _login_method_count(user: dict) -> int:
"""可用登录方式数:手机号 / 微信(网页) / 小程序。用于解绑守卫(至少留一种)。"""
return sum(1 for k in ("phone", "wx_openid", "wx_mini_openid") if (user or {}).get(k))
async def _bind_phone_merge(db: Database, user: dict, phone: str) -> dict:
"""给用户绑定手机号;若该手机号已被其它账号占用,则按「手机号持有者=keeper」合并。
返回登录账号(可能是合并后的 keeper)。keeper 已持有该手机号,absorbed=当前账号并入。
"""
owner = await db.users.find_by_phone(phone)
if owner and owner["id"] != user["id"]:
# keeper=已持有该手机号的账号;当前账号被并入 → 微信等身份挂在 keeper 下
keeper = await db.users.merge_accounts(owner["id"], user["id"])
return keeper or owner
fresh = await db.users.set_phone(user["id"], phone)
return fresh or user
@router.post("/bind-phone", response_model=LoginResponse, summary="当前账号绑定手机号")
async def bind_phone(
req: BindPhoneRequest,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""绑定手机号(验证码)。若该手机号已被其它账号占用 → 按手机号合并为同一账号。
合并后当前会话切换为 keeper(返回其新令牌),前端应更新本地令牌。
"""
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
if not _is_phone(req.phone):
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
try:
sms.verify(req.phone, req.code)
except sms.SmsError as exc:
raise HTTPException(status_code=401, detail=str(exc))
merged = await _bind_phone_merge(db, user, req.phone)
identity = await _ensure_opc_identity(db, merged["id"])
await db.audit.add(
action="bind-phone", resource="auth", resource_id=merged["id"],
detail=f"bind phone {req.phone} (merged)" if merged["id"] != user["id"] else f"bind phone {req.phone}",
user_id=merged["id"],
)
return await _login_response(db, merged, identity)
@router.post("/unbind", summary="解绑登录方式(保留至少一种)")
async def unbind(req: UnbindRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
"""解除已绑定的登录方式:phone / wx / wx_mini。不能解绑到没有任何可用登录方式。"""
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
if req.type not in ("phone", "wx", "wx_mini"):
raise HTTPException(status_code=400, detail="type 需为 phone/wx/wx_mini")
# 解绑后仍保留至少一种登录方式
remaining = _login_method_count(user) - (1 if user.get({ # noqa: E501
"phone": "phone", "wx": "wx_openid", "wx_mini": "wx_mini_openid",
}[req.type]) else 0)
if remaining <= 0:
raise HTTPException(status_code=409, detail="至少要保留一种登录方式(手机号/微信/小程序)")
fresh = await db.users.clear_binding(user["id"], req.type)
await db.audit.add(
action="unbind", resource="auth", resource_id=user["id"],
detail=f"unbind {req.type}", user_id=user["id"],
)
return {**await db.users.to_profile(fresh), "unbound": req.type}
# ===========================================================================
# 微信开放平台扫码登录(标准 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 + 小程序码图片。
用于「小程序扫码登录其它端口」:桌面/admin/网页登录页展示该码,
用户用 **微信扫一扫** → 自动打开小程序「扫码登录」页(复用其登录身份)确认,
目标端口轮询取得登录令牌。scene 用短 tokenwxacode 限 32 字符)。
未配置 WECHAT_APPID/SECRET 时退化为普通二维码(qr_url),仅支持「小程序内
scanCode 路径」,无法被微信自动拉起小程序。
"""
if not config.AUTH_WECHAT_LOGIN:
raise HTTPException(status_code=503, detail="小程序登录未开启")
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="认证未开启")
scene = f"mp_{secrets.token_hex(10)}" # 3+20 = 23 字符
wx_qr_store.start(scene)
# 生成小程序码(微信扫码自动打开小程序 pages/scan-login 并携带 scene
try:
png = await wechat.get_wxacode(scene, page="pages/scan-login/index")
b64 = base64.b64encode(png).decode("ascii")
return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True}
except wechat.WechatError:
# 无凭据/失败兜底:返回可扫描的 URL,前端按 qr_url 渲染普通二维码
qr_url = f"https://opc.pinesound.cn/mp/login?scene={scene}"
return {"scene": scene, "qr_url": qr_url, "mp_enabled": False}
@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>"
)