feat(sso): 跨子域 SSO 反向链路(netmgr 面板 → csctalon)
方向:本系统为消费方,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:
13
.env.example
13
.env.example
@@ -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 已显式注入容器内路径
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
# 使"数据写到哪"也受模式驱动,而非散落的字面量。
|
||||
|
||||
73
backend/app/services/netmgr_sso.py
Normal file
73
backend/app/services/netmgr_sso.py
Normal 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"]
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user