feat(sso): 跨子域 SSO 反向链路(netmgr 面板 → csctalon)
Some checks failed
gitleaks / gitleaks (push) Successful in 8s
backend-test / backend-lint-test (push) Failing after 31m59s
backend-test / frontend-lint-test (push) Successful in 50s

方向:本系统为消费方,netmgr 面板(panel.kimirock.top)为身份源。
面板管理员已登录后,点导航「数据分析系统」入口 → 面板签 60 秒 HMAC
一次性票据 → 302 到 /api/v1/auth/sso → 本系统验票 → 复用自身登录链路
签 JWT → 引导页写 localStorage 跳转,实现无感免密登录。

- 新增 services/netmgr_sso.py:HMAC-SHA256 验票(常量时间比较,60s+540s
  时钟偏差容忍),与面板 sso.js 票据格式逐字符对齐
- auth.py 加 GET /sso 端点:无票 302 回面板签票,坏票 403,好票查账号
  签 JWT;复用 login-json 的 create_access_token + token_version 机制
- 前端 axios 401 拦截:JWT 过期自动跳 /api/v1/auth/sso 续期(面板有会话
  则无感续期);在 /login 页不跳 SSO 避免死循环
- settings 加 NETMGR_SSO_SECRET/PANEL_URL/DEFAULT_USER;.env.example 同步
- 与既有 csctalon→netmgr 方向(SSO_SHARED_SECRET)是两个独立密钥,不混用
- 不依赖 OpenResty 边缘网关:用户从面板入口主动跳入,www 站点保持纯反代
This commit is contained in:
2026-09-23 13:49:15 +08:00
parent 84f6257e54
commit bb89347907
5 changed files with 200 additions and 5 deletions

View File

@@ -54,6 +54,19 @@ SSO_TICKET_TTL_SECONDS=60
SSO_TALON_URL_LAN=http://192.168.10.21:4080
SSO_TALON_URL_ZT=http://10.147.17.22:4080
# ---------- 跨子域 SSO(反向:netmgr 面板 → 本系统)----------
# 方向:本系统是消费方,netmgr 面板(panel.kimirock.top)是身份源。
# 面板管理员登录后 → 面板签 60 秒 HMAC 票据 → 302 到本系统 /api/v1/auth/sso
# → 本系统验票 → 复用自身登录链路签 JWT → 引导页写 localStorage。
# 与上方 SSO_SHARED_SECRET(本系统→面板方向)是两个独立密钥,不可混用。
# ⚠️ NETMGR_SSO_SECRET 必须与 netmgr 侧 /opt/netmgr/.env 的 SSO_CSCTALON_SECRET 逐字符一致
# (openssl rand -hex 32 生成,两侧各存一份)。留空 = 反向 SSO 关闭(不影响本系统登录)。
NETMGR_SSO_SECRET=
# 面板 SSO 签票入口(无票/票据过期时回跳此地址重新签票)
NETMGR_SSO_PANEL_URL=https://panel.kimirock.top/sso/csctalon
# 票据 sub 映射的本系统账号(面板固定签此用户名,须与本系统已存在且 active 的账号一致)
NETMGR_SSO_DEFAULT_USER=admin
# ---------- 上传 ----------
# 本地开发默认值 = 容器内相对路径(配合 compose 默认挂载 ./uploads:/app/uploads)。
# ⚠️ docker-compose.yml backend environment 已显式注入容器内路径

View File

@@ -1,7 +1,12 @@
"""认证路由:登录、当前用户信息"""
import json
import re
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -14,6 +19,7 @@ from app.core.security import create_access_token, verify_password
from app.models.user import User
from app.schemas.common import R
from app.schemas.user import LoginRequest, SsoEntriesOut, SsoTicketOut, Token, UserOut
from app.services.netmgr_sso import verify_ticket as verify_netmgr_ticket
from app.services.operation_logger import log_operation
from app.services.sso_ticket import build_netmgr_entry_url
@@ -122,6 +128,91 @@ def me(current_user: CurrentUser):
return R(data=UserOut.model_validate(current_user))
# 安全的 next 路径正则:仅允许站内 / 开头路径,防开放重定向
_SSO_SAFE_NEXT = re.compile(r"^/[A-Za-z0-9\-._~!$&'()*+,;=:@/?%]{0,511}$")
@router.get("/sso", response_class=HTMLResponse, summary="面板跨子域 SSO 入口(验票发 JWT)")
def sso_enter(
request: Request,
ticket: str | None = None,
next: str = "/",
db: Session = Depends(get_db),
):
"""netmgr 面板 HMAC 票据验票 → 发本系统 JWT → 引导页写 localStorage 跳转。
方向:**本系统是消费方**,netmgr 面板(panel.kimirock.top)是身份源。
- 无票:302 回面板签票(面板有会话会立刻签新票,无感知)
- 坏票/过期:403(不回面板,避免坏票循环)
- 好票:验 sub 对应账号存在且 active → 签 JWT → 返回引导 HTML 写 localStorage 后跳转
与 /auth/sso/ticket(本系统→面板方向)是独立链路,不共享密钥。
"""
safe_next = next if _SSO_SAFE_NEXT.match(next) else "/"
sub = verify_netmgr_ticket(ticket) if ticket else None
if sub is None:
if ticket:
# 有票但验票失败:直接 403,不回面板(避免坏票循环)
log_operation(
db, method="GET", path="/api/v1/auth/sso",
action="认证.SSO验票失败", target_type="auth", status_code=403,
detail={"reason": "invalid_ticket"}, ip=_client_ip(request),
)
return JSONResponse(
{"detail": "invalid or expired SSO ticket"}, status_code=403
)
# 无票:回面板签票
panel_url = settings.NETMGR_SSO_PANEL_URL
return RedirectResponse(
f"{panel_url}?{urlencode({'next': safe_next})}"
)
# 验票成功:按 sub 查账号(sub = 面板固定映射的用户名)
user = db.scalar(select(User).where(User.username == sub))
if not user:
log_operation(
db, username=sub, method="GET", path="/api/v1/auth/sso",
action="认证.SSO拒绝", target_type="auth", status_code=403,
detail={"reason": "user_not_found", "sub": sub}, ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"SSO 用户不存在: {sub}"}, status_code=403
)
if not user.is_active:
log_operation(
db, user_id=user.id, username=user.username, method="GET", path="/api/v1/auth/sso",
action="认证.SSO拒绝", target_type="auth", status_code=403,
detail={"reason": "账号已停用"}, ip=_client_ip(request),
)
return JSONResponse({"detail": "账号已停用"}, status_code=403)
# 复用 login-json 的签发逻辑(带 tkv=token_version)
token = create_access_token(
user.id,
extra={"username": user.username, "role": user.role, "name": user.display_name},
token_version=user.token_version,
)
user_out = UserOut.model_validate(user)
log_operation(
db, user_id=user.id, username=user.username, method="GET", path="/api/v1/auth/sso",
action="认证.SSO登录", target_type="auth", status_code=200, ip=_client_ip(request),
)
# 引导页:写 localStorage(与前端 stores/auth.js login 约定一致),然后跳转。
# user 存为 JSON 字符串(前端 JSON.parse(localStorage.getItem('user')))。
user_json = json.dumps(user_out.model_dump(mode="json"), ensure_ascii=False)
bootstrap = (
"<!doctype html><meta charset=utf-8>\n"
"<script>\n"
f"localStorage.setItem('token', {json.dumps(token)});\n"
f"localStorage.setItem('user', {json.dumps(user_json)});\n"
f"location.replace({json.dumps(safe_next)});\n"
"</script>"
)
return HTMLResponse(bootstrap, headers={"Cache-Control": "no-store"})
@router.get(
"/sso/entries",
response_model=R[SsoEntriesOut],

View File

@@ -172,6 +172,18 @@ class Settings(BaseSettings):
SSO_TALON_URL_LAN: str = ""
SSO_TALON_URL_ZT: str = ""
# ── 跨子域 SSO(反向:netmgr 面板 → 本系统)─────────────────────────
# 方向:**本系统是消费方**,netmgr 面板(panel.kimirock.top)是身份源。
# 面板管理员已登录 → 面板签 60 秒 HMAC 一次性票据 → 302 到本系统 /api/v1/auth/sso
# → 本系统验票 → 复用自身登录链路签 JWT → 引导页写 localStorage 跳转。
# 与上方 SSO_SHARED_SECRET(本系统→面板方向)是**两个独立密钥**,不可混用。
# ⚠️ NETMGR_SSO_SECRET 必须与 netmgr 侧 /opt/netmgr/.env 的 SSO_CSCTALON_SECRET 逐字符一致。
NETMGR_SSO_SECRET: str = ""
# 面板 SSO 签票入口(无票/票据过期时回跳此地址重新签票)
NETMGR_SSO_PANEL_URL: str = "https://panel.kimirock.top/sso/csctalon"
# 票据 sub 映射的本系统账号(面板固定签此用户名,须与本系统已存在且 active 的账号一致)
NETMGR_SSO_DEFAULT_USER: str = "admin"
# 上传
# ★ 2026-09-22: 默认值由 profile 决定(dev=./uploads / test=/tmp/... / prod=/app/uploads)
# 使"数据写到哪"也受模式驱动,而非散落的字面量。

View File

@@ -0,0 +1,73 @@
"""跨子域 SSO 验票(netmgr 面板 → 本系统,反向于 sso_ticket.py)
方向:**本系统是消费方**,netmgr 面板(panel.kimirock.top)是身份源。
面板 GET /sso/csctalon 签发 60 秒一次性 HMAC 票据 → 302 到本系统 /api/v1/auth/sso
→ 本模块验票 → 返回 sub(=面板固定映射的用户名)
票据格式(与 netmgr src/admin-panel/routes/sso.js 逐字符对齐,改一处必须改两处):
base64url(payload) + "." + base64url(HMAC-SHA256(secret, payload))
payload = {"sub": 用户名, "exp": 秒级时间戳, "jti": 16 位 hex}
安全约束:
- 60 秒过期(容忍 ±540 秒时钟偏差,防 exp 异常值)
- 常量时间比较签名,防计时侧信道
- 密钥只从 settings 读,禁止硬编码
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from app.core.config import settings
# 票据有效期(秒),须与面板侧 TTL_SECONDS 一致
TICKET_TTL_SECONDS = 60
# 容忍时钟偏差上限:签发后最多 10 分钟内有效(与面板 verifyTicket 的 exp-out-of-range 对齐)
_MAX_SKEW_SECONDS = 540
def _b64url_decode(s: str) -> bytes:
"""base64url 解码(自动补填充)。"""
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def verify_ticket(ticket: str) -> str | None:
"""验票。返回 sub 或 None(坏票/过期/签名不符/未配置密钥)。
常量时间比较签名,防计时侧信道。
"""
secret = settings.NETMGR_SSO_SECRET
if not secret:
return None
if not isinstance(ticket, str) or len(ticket) < 10 or len(ticket) > 2048:
return None
try:
payload_b64, sig_b64 = ticket.rsplit(".", 1)
except ValueError:
return None
expected = hmac.new(
secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256
).digest()
try:
got = _b64url_decode(sig_b64)
except Exception:
return None
if not hmac.compare_digest(expected, got):
return None
try:
claims = json.loads(_b64url_decode(payload_b64))
except Exception:
return None
if not isinstance(claims.get("sub"), str) or not isinstance(claims.get("exp"), int):
return None
now = int(time.time())
if claims["exp"] < now:
return None
# 容忍时钟偏差:签发时间(exp - TTL)不能比 now + skew 还晚(防 exp 异常远未来值)
if claims["exp"] - TICKET_TTL_SECONDS > now + _MAX_SKEW_SECONDS:
return None
return claims["sub"]

View File

@@ -46,11 +46,17 @@ request.interceptors.response.use(
detail = detail?.msg || JSON.stringify(detail)
}
if (status === 401) {
const authStore = useAuthStore()
// 用 localLogout 而非 logout: 此时 token 已失效, 调 /auth/logout 会再 401 形成递归
authStore.localLogout()
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
// 跨子域 SSO:JWT 过期自动跳 /api/v1/auth/sso 续期。
// 面板有会话 → 签新票 302 回来验票发新 JWT → 引导页写 localStorage 跳回原路径(无感续期);
// 面板无会话 → 面板登录后同样走通。在 /login 页不跳 SSO(避免死循环),仅清本地态。
const currentPath = window.location.pathname + window.location.search + window.location.hash
if (currentPath.startsWith('/login')) {
const authStore = useAuthStore()
authStore.localLogout()
if (!silent) ElMessage.error('登录已过期,请重新登录')
} else {
window.location.href = `/api/v1/auth/sso?next=${encodeURIComponent(currentPath)}`
}
} else if (!silent) {
ElMessage.error(String(detail))
}