From 20a1c8b56943efaaaf361d651bb8e2b0dc6f24d3 Mon Sep 17 00:00:00 2001 From: figmar Date: Tue, 1 Sep 2026 19:31:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20Casdoor=20SSO=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=BF=90=E7=BB=B4=E6=89=8B=E5=86=8C=20+=20=E6=B3=9B=E5=8C=96?= =?UTF-8?q?=20E2E=20=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=EF=BC=88=E6=B2=89?= =?UTF-8?q?=E6=B7=80=2031.23=20=E8=B8=A9=E5=9D=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CASDOOR_SSO_DEPLOYMENT-zh.md | 97 +++++++++++++++++++++++++++++++ scripts/casdoor-sso/e2e-check.sh | 86 +++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 docs/CASDOOR_SSO_DEPLOYMENT-zh.md create mode 100755 scripts/casdoor-sso/e2e-check.sh diff --git a/docs/CASDOOR_SSO_DEPLOYMENT-zh.md b/docs/CASDOOR_SSO_DEPLOYMENT-zh.md new file mode 100644 index 0000000..5b2dccc --- /dev/null +++ b/docs/CASDOOR_SSO_DEPLOYMENT-zh.md @@ -0,0 +1,97 @@ +# DeepData × Casdoor SSO 部署运维手册(31.23) + +> 适用范围:DeepData(`com.deepdata`)网关单点登录接入自建 Casdoor v2.153.0。 +> 生产实例:31.23(Casdoor `:1013`,DeepData 网关 `:8080`,前端/入口 `deepdata.yhdd.apescale.com:1014`)。 +> 代码版本:≥ `4e2ec55`(M3:移除 accountOnly,本地登录与 SSO 共存;登录时自动建号,无定时同步)。 + +## 1. 架构与登录流 + +``` +浏览器 → DeepData 网关 /api/user/oauth/login?next=… + → 302 Casdoor /login/oauth/authorize(state+next 存 HttpOnly cookie,TTL 300s) + → Casdoor 登录/同意 + → 回调 /api/user/oauth/callback?code&state + → 网关换 token → JWKS 验 id_token → upsert 本地用户(自动建号/刷组刷角色) + → 302 next?sso_token=<本地HS256 JWT>(含 role/groups claims) + → 前端 callback.html 种 token 进入应用 +``` + +失败时回跳 `next?sso_error=`(`state_mismatch` / `code_exchange` / `id_token_verify` / `internal`)。 + +角色映射:Casdoor 组 `deepdata-admin|dev|annotator|viewer`(组路径 `datamate/`)→ 本地 `role`,由 `RoleResolver` 按「映射 > 主角色 > defaultRole」解析;组与角色随每次 SSO 登录刷新。 + +## 2. Casdoor 侧一次性配置(生产现状) + +| 对象 | 值 | 说明 | +|------|-----|------| +| 组织 | `admin/built-in`、`admin/datamate` | `password_type=bcrypt` | +| 组(owner=datamate) | `deepdata-admin` `deepdata-dev` `deepdata-annotator` `deepdata-viewer` | 与 casbin 规则对齐 | +| 用户(owner=datamate) | `dmin`(4 组)、`viewer1`(viewer) | **`tag='DeepData'` 必须与应用 tags 一致**;建议给真实 email | +| 证书 | `admin/cert-built-in` | 应用引用用**裸名** `cert-built-in` | +| 应用 | `admin/DataMate` | clientId `144f4d00553aa7db0aab`;tags `["DeepData"]`;tokenFields `["Groups"]`;`redirectUri=https://deepdata.yhdd.apescale.com:1014/api/user/oauth/callback`;**`expire_in_hours ≥ 1`** | +| Casbin | dmin 4 条 / viewer1 1 条 | 角色→组权限 | + +### 踩坑清单(均已踩过,排查先对这里) + +1. **`expire_in_hours` 必须 ≥ 1**:应用列名是 `expire_in_hours`(double),不是 token_expiration。为 0 时 `expires_in=0`,id_token `exp=iat`,网关验证报 `JWT expired N milliseconds ago`。 + `UPDATE application SET expire_in_hours=1 WHERE owner='admin' AND name='DataMate';` +2. **用户 tag 必须在应用 tags 里**:否则 casdoor 登录报 `User's tag: is not listed in the application's tags`。 +3. **`/api/login` 收 JSON body**(不是表单):`{"type":"login","application":"DataMate","username":"dmin","password":"…","organization":"datamate","autoSignin":true}`。 +4. **`/api/grant-consent` 的 `application` 必须全名** `admin/DataMate`(裸名报 `Invalid application`);session cookie 与登录**同 host**(cookie 域陷阱)。 +5. **authorize 无服务端 302**:带 session 的 GET 只返回 SPA 页面,重定向由前端 `grant-consent` 后完成——脚本化验证直接调 `POST /api/grant-consent`。 + +## 3. DeepData 侧配置 + +`deployment/docker/deepdata/.env`(compose 注入,变量名映射见 `docker-compose.yml`): + +```ini +DEEPDATA_CASDOOR_ENABLED=true +DEEPDATA_CASDOOR_ISSUER=https://sso.yhdd.apescale.com:1014 +DEEPDATA_CASDOOR_AUTHORIZE_URI=http://192.168.31.23:1013/login/oauth/authorize +DEEPDATA_CASDOOR_TOKEN_URI=http://192.168.31.23:1013/api/login/oauth/access_token +DEEPDATA_CASDOOR_JWKS_URI=http://192.168.31.23:1013/.well-known/jwks.json +DEEPDATA_CASDOOR_CLIENT_ID=144f4d00553aa7db0aab +DEEPDATA_CASDOOR_CLIENT_SECRET=*** +DEEPDATA_CASDOOR_REDIRECT_URI=https://deepdata.yhdd.apescale.com:1014/api/user/oauth/callback +JWT_SECRET=<不变,本地 JWT 签名密钥> +``` + +> ISSUER 用公网域名(id_token issuer 断言用);三个 URI 用内网直连(网关→casdoor 同机)。 + +## 4. 构建与部署 + +```bash +cd /opt/deepdata/deepdata +make gateway-docker-build frontend-docker-build # 产物 deepdata-{gateway,frontend}:latest +cd deployment/docker/deepdata +docker compose up -d --force-recreate deepdata-gateway deepdata-frontend +``` + +注意:跳板机链路构建长任务易断连,长构建用 `nohup … > /tmp/gw_build.log 2>&1 &` 后台跑再轮询日志。 +同步代码到 31.23 **只按文件同步、禁用 `rsync --delete`**,同步后 `git hash-object` 逐文件核对。 + +## 5. 验证 + +```bash +GW_BASE=https://deepdata.yhdd.apescale.com:1014 \ +CASDOOR_BASE=http://192.168.31.23:1013 \ +SSO_CLIENT_ID=144f4d00553aa7db0aab \ +LOCAL_ADMIN_PASSWORD=<本地admin密码> \ +SSO_TEST_USERS="dmin::admin viewer1::viewer" \ +bash scripts/casdoor-sso/e2e-check.sh +``` + +覆盖:配置公开且无 secret 泄漏 → 本地登录(与 SSO 共存)→ SSO 全链路(authorize→登录→consent→callback→sso_token→/me 角色断言)。 + +网络注意:公网域名经 222.128.20.101 转发到 31.23:1014,**不要用 `--resolve` 把域名指到 192.168.31.23**(该地址本地未监听);E2E 直接访问公网域名即可。 + +## 6. 排障 + +| 现象 | 原因 | 处理 | +|------|------|------| +| `sso_error=id_token_verify` + 网关日志 `JWT expired` | 应用 `expire_in_hours=0` | 见 §2 坑 1 | +| `sso_error=internal` + `provision race … reusing existing user` 后 NPE | 本地 email 列 NOT NULL UNIQUE,空 email 互撞(代码已修为空 email 合成 `username@casdoor.local`) | 升级网关镜像;存量行 `UPDATE users SET email=… WHERE email='';` | +| casdoor 登录 `User's tag: is not listed…` | 用户 tag 不在应用 tags | `UPDATE user SET tag='DeepData' WHERE owner='datamate';` | +| grant-consent `Invalid application` | application 用了裸名 | 用 `admin/DataMate` | +| authorize 返回 200 不跳转 | 正常(SPA 行为);若脚本验证用 grant-consent | — | +| `/sso/casdoor/config` `enabled:false` | `DEEPDATA_CASDOOR_ENABLED` 未注入 | 检查 .env + compose 变量名映射 | diff --git a/scripts/casdoor-sso/e2e-check.sh b/scripts/casdoor-sso/e2e-check.sh new file mode 100755 index 0000000..b8adeaa --- /dev/null +++ b/scripts/casdoor-sso/e2e-check.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# DeepData × Casdoor SSO 全链路 E2E 检查(在能同时访问网关与 Casdoor 的机器上运行) +# +# 用法: +# GW_BASE=https://deepdata.yhdd.apescale.com:1014 \ +# CASDOOR_BASE=http://192.168.31.23:1013 \ +# SSO_CLIENT_ID= SSO_CLIENT_SECRET= \ +# LOCAL_ADMIN_PASSWORD=*** \ +# bash e2e-check.sh +# +# 可选环境变量(有默认值,按实际部署修改): +# SSO_APP_NAME=DataMate Casdoor 应用名 +# SSO_APP_OWNER=admin Casdoor 应用 owner(grant-consent 需 owner/name 全名) +# SSO_ORG=datamate Casdoor 组织名(登录用) +# SSO_REDIRECT_URI=$GW_BASE/api/user/oauth/callback +# SSO_TEST_USERS="dmin:Dmin@2026:admin viewer1:Viewer1@2026:viewer" 用户名:密码:期望角色 +# LOCAL_ADMIN=admin 本地管理员(验证本地登录共存) +set -u + +GW="${GW_BASE:?GW_BASE required, e.g. https://deepdata.yhdd.apescale.com:1014}" +CD="${CASDOOR_BASE:?CASDOOR_BASE required, e.g. http://192.168.31.23:1013}" +APP="${SSO_APP_NAME:-DataMate}" +APP_FULL="${SSO_APP_OWNER:-admin}/$APP" +ORG="${SSO_ORG:-datamate}" +REDIR="${SSO_REDIRECT_URI:-$GW/api/user/oauth/callback}" +CLIENT_ID="${SSO_CLIENT_ID:?SSO_CLIENT_ID required}" +ADMIN_U="${LOCAL_ADMIN:-admin}" +ADMIN_P="${LOCAL_ADMIN_PASSWORD:*** LOCAL_ADMIN_PASSWORD required}" + +TMPD=$(mktemp -d); trap 'rm -rf "$TMPD"' EXIT +fail=0 +ck() { if [ "$1" = "0" ]; then echo "PASS: $2"; else echo "FAIL: $2"; fail=1; fi; } + +echo "=== [1] /sso/casdoor/config ===" +CFG=$(curl -sk "$GW/sso/casdoor/config") +echo "$CFG" +echo "$CFG" | grep -q '"enabled":true' && ck 0 "enabled=true" || ck 1 "enabled" +# clientSecret 不得出现在公开配置里 +if [ -n "${SSO_CLIENT_SECRET:-}" ] && echo "$CFG" | grep -q "$SSO_CLIENT_SECRET"; then + ck 1 "clientSecret 泄漏"; else ck 0 "无 secret 泄漏"; fi + +echo "=== [2] 本地登录 $ADMIN_U(accountOnly 已移除,本地登录与 SSO 共存)===" +LOGIN=$(curl -sk -X POST "$GW/api/user/login" -H 'Content-Type: application/json' \ + -d "{\"username\":\"$ADMIN_U\",\"password\":\"$ADMIN_P\"}") +TOK=$(echo "$LOGIN" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') +[ -n "$TOK" ] && ck 0 "本地登录拿 token" || { echo "$LOGIN"; ck 1 "本地登录"; } +ME=$(curl -sk "$GW/api/user/me" -H "Authorization: Bearer $TOK") +echo "$ME" + +# SSO 单用户链路: 网关登录页 → casdoor JSON 登录 → grant-consent 取 code → 网关 callback → /me +sso_test() { + local U="$1" P="$2" EXPECT="$3" + local GJ="$TMPD/gw_$U" CJ="$TMPD/cd_$U" HDRS AUTHURL STATE LR CODE CB FINAL SSTOK ME2 + HDRS=$(curl -sk -c "$GJ" -D - -o /dev/null "$GW/api/user/oauth/login?next=/sso/callback.html") + AUTHURL=$(echo "$HDRS" | grep -i '^location:' | tr -d '\r' | awk '{print $2}') + STATE=$(echo "$AUTHURL" | sed -n 's/.*state=\([^&]*\).*/\1/p') + [ -n "$AUTHURL" ] && [ -n "$STATE" ] && ck 0 "$U 拿到 authorize URL" || { echo "authurl=$AUTHURL"; ck 1 "$U authorize"; return; } + LR=$(curl -s -c "$CJ" -X POST -H 'Content-Type: application/json' \ + -d "{\"type\":\"login\",\"application\":\"$APP\",\"username\":\"$U\",\"password\":\"$P\",\"organization\":\"$ORG\",\"autoSignin\":true}" \ + "$CD/api/login") + echo "$LR" | grep -q '"status": *"ok"' && ck 0 "$U casdoor 登录" || { echo "$LR"; ck 1 "$U casdoor 登录"; return; } + CODE=$(curl -s -b "$CJ" -X POST -H 'Content-Type: application/json' \ + -d "{\"application\":\"$APP_FULL\",\"clientId\":\"$CLIENT_ID\",\"provider\":\"\",\"signinMethod\":\"\",\"responseType\":\"code\",\"redirectUri\":\"$REDIR\",\"scope\":\"openid profile email\",\"state\":\"$STATE\",\"nonce\":\"\",\"challenge\":\"\",\"resource\":\"\"}" \ + "$CD/api/grant-consent" | sed -n 's/.*"data": *"\([^"]*\)".*/\1/p') + [ -n "$CODE" ] && ck 0 "$U 拿到 authorization code" || { ck 1 "$U grant-consent"; return; } + CB=$(curl -sk -b "$GJ" -D - -o /dev/null "$GW/api/user/oauth/callback?code=$CODE&state=$STATE") + FINAL=$(echo "$CB" | grep -i '^location:' | tr -d '\r' | awk '{print $2}') + echo "final: $FINAL" + SSTOK=$(echo "$FINAL" | sed -n 's/.*sso_token=//p') + [ -n "$SSTOK" ] && ck 0 "$U 拿到 sso_token" || { echo "$CB" | head -5; ck 1 "$U sso_token"; return; } + ME2=$(curl -sk "$GW/api/user/me" -H "Authorization: Bearer $SSTOK") + echo "$ME2" + echo "$ME2" | grep -q "\"role\":\"$EXPECT\"" && ck 0 "$U 角色=$EXPECT" || ck 1 "$U 角色(期望 $EXPECT)" +} + +i=3 +for entry in ${SSO_TEST_USERS:-}; do + U="${entry%%:*}"; rest="${entry#*:}"; P="${rest%%:*}"; EXPECT="${rest#*:}" + echo "=== [$i] SSO $U → $EXPECT ===" + sso_test "$U" "$P" "$EXPECT" + i=$((i+1)) +done + +echo "" +echo "=== 汇总 fail=$fail ===" +exit "$fail"