SSO 单点登录与统一登出
仓库归属(2026-09-22 起):account 服务与门户的部署清单(
deploy/k8s、deploy/ci中 account/platform 相关文件)已迁veyra-bss仓;router 侧(deploy/router-prod/)在veyra-router仓。本文流程描述不变,文件路径以各仓为准。
account 服务作为 SaaS 产品线的统一认证方(IdP):用户在 account.<域名> 登录一次后,访问其他接入的服务(如 Router 控制台、运营门户、工作台)免密直达;统一登出则一次退出所有接入服务。
访问入口
| 端点 | 说明 |
|---|---|
https://account.<域名>/login | 门户登录页(account-portal SPA:账号密码 / 验证码 / 微信登录 + 注册入口) |
https://account.<域名>/oauth/authorize | 授权端点(已登录则静默放行,未登录先到门户登录页) |
https://account.<域名>/oauth/returnto | SSO 回跳恢复(门户登录成功后续走 authorize;跳转目标由服务端签发验签,无开放跳转面) |
https://account.<域名>/.well-known/jwks.json | 令牌验签公钥(接入方缓存,密钥轮换自动生效) |
https://account.<域名>/oauth/logout | 统一登出(清所有接入服务的本地会话后回主站;未配置落地地址时回落本域落地页)。账号中心门户自身的「退出」在 SSO 开启时也走本端点 |
https://account.<域名>/loggedout | 已登出落地页(ACCOUNT_LOGOUT_REDIRECT_URL 未配置时的兜底) |
接入方后端兑换令牌的目标地址按部署形态取(由 BSS 侧给出,部署清单以环境变量注入): 同集群
http://bss:8100/oauth/token;跨集群走 BSS 内网 TLS 入口https://bss.internal.<域名>:8443/oauth/token(UA_ACCOUNT_BASE_URL)。内部路径 不开放公网(部署验收项:公网访问/oauth/token应不可达)。
接入方接入步骤
部署即注册:account 启动时按
BSS_OAUTH_SEED_CLIENTS声明幂等自动注册一等公民接入方(console 工作台、运营门户),部署清单是注册的唯一事实源,换环境无需任何手工步骤。声明为 JSON 数组(登出地址可省略;留空则不播种):yaml- name: BSS_OAUTH_SEED_CLIENTS value: >- [{"client_id":"veyraos-console","name":"VeyraOS Console", "redirect_uri":"https://console.<域名>/sso/callback", "backchannel_logout_uri":"https://console.<域名>/api/manager/auth/sso/backchannel-logout"}, {"client_id":"saas-ops","name":"Platform Portal", "redirect_uri":"https://platform.<域名>/oauth/callback"}]- 重复启动按声明整组覆盖(redirect_uri / 登出地址 / active),手工临时调整重启后回归声明值;
- 需要直调 account 业务 API 的接入方在声明条目追加
"scope":"openid portal",兑换时同步下发业务令牌对; - 统一登出用后通道(
backchannel_logout_uri):登出时 IdP 以服务端 POST 直接把签名logout_token投给接入方后端,不依赖浏览器逐跳跳转,新接入一律用后通道。frontchannel_logout_uri为遗留通道(前端页面接收,浏览器跳转链易断),仅存量接入保留; - 临时调试/第三方接入仍可用 CLI 单独注册(幂等,可重复执行更新):
bashkubectl -n <namespace> exec deploy/account -- python -m app.manage register-client veyraos-console \ --name "VeyraOS Console" \ --redirect-uri https://console.<域名>/sso/callback \ --backchannel-logout-uri https://console.<域名>/api/manager/auth/sso/backchannel-logout \ --scope openid- 回调地址按精确匹配校验(含 scheme/host/path/query),新增回调需再次注册。
接入方按 OIDC 授权码 + PKCE(S256) 流程接入:未登录 → 携
client_id/redirect_uri/code_challenge(S256) /state跳转/oauth/authorize→ 回调拿code→ 集群内用code_verifier兑换id_token→ 验签后建本地会话。未登录时 authorize 302 到门户登录页(
/login?sso=1,account-portal SPA);门户登录/注册/微信登录成功后自动经/oauth/returnto续走 authorize(returnTo 暂存于 HttpOnly 签名 cookie,跳转目标恒为服务端签发的 authorize URL),无需门户侧感知接入方。登出:接入方提供单一「退出」入口,默认跳
https://account.<域名>/oauth/logout统一登出(一次退出所有接入服务;SSO 未配置的私有化形态退化为仅清本域会话)。
VeyraOS 管理台(veyraos-console)
管理台登录已整体收口 account:无独立登录页,未登录访问 console.<域名> 自动经 /oauth/authorize 跳转账号中心登录,授权码回跳 {admin}/sso/callback 后由 manager(BFF)集群内兑换 id_token 并验签,随后按 sub 建/查本地用户(首次登录自动建档并同步组织投影)签发管理台令牌。state 与 PKCE verifier 由 manager 落库(oauth_login_states 表,单次消费、10 分钟有效),前端不持有。
- client_id
veyraos-console,scopeopenid portal,回调https://console.<域名>/sso/callback(另有账号中心入口免登回调/sso/account-callback);私有化离线包经BSS_OAUTH_SEED_CLIENTS随部署声明播种,云上经register-client注册; - 首次登录建档时,本地登录名由 account 用户 ID 派生(
account-<ID 前 8 位>),account 返回的显示名称(姓名)落本地「姓名」字段,页面展示优先使用姓名;登录名为内部标识,不取自姓名(姓名可能重复或含中文); - 首管理员初始化改为向 account 导入(安装 Job 经
import-staff落号),manager 侧按账号反查建关联行——存量从本地账号体系升级的环境须先完成账号迁移(导出 → 导入 → 回填关联),再开启 SSO 登录,否则旧账号无法从管理台登录; - SSO 未配置(缺 client 注册或管理台环境变量)时登录页显示故障态而非本地表单——管理台不提供绕过账号中心的登录入口;
- 授权请求携带
nonce并在 id_token 中校验回声(防重放);state 与 PKCE verifier 由 manager 落库单次消费; - 统一登出(后通道):管理台「退出」清本地会话后自动联动跳
/oauth/logout登出 IdP 会话(SSO 未配置时仅清本地)。用户在账号中心任一处登出后,IdP 服务端回调管理台POST /api/manager/auth/sso/backchannel-logout(匿名可达,凭据即logout_token的 RS256 签名,走同一 JWKS 验签),manager 校验iss/aud/exp+events事件键(拒绝携带nonce的令牌)后,把该账号的users.session_invalid_before置为登出时刻——早于该水位签发的 access / refresh 令牌随即失效,本地会话由此可被服务端撤销(无状态 JWT 自身做不到,靠这一列落地)。校验失败返回 400 且不清任何会话;IdP 不可达/JWKS 取不到返回 503 由 IdP 退避重投;未关联到本地账号或重复投递一律 200(幂等,绝不 404); - 前通道
/sso/logout接收端保留作升级窗口内的旧登出链兜底,新接入不再注册frontchannel_logout_uri;
存量环境账号迁移 runbook(本地账号体系 → account)
从旧版本(管理台本地账号密码登录)升级到 SSO 收口版本时,必须先完成本迁移再升级管理台——新版本登录页不再有本地表单,未迁移的账号将完全无法登录。全程约一个登录不可用窗口,建议在低峰期执行。
导出存量用户(在 manager 数据库上执行,生成
staff_dump.json):bashkubectl -n veyraos exec -i deploy/manager -- python - <<'PY' > staff_dump.json import asyncio, json from sqlalchemy import text from pkg.common.database import engine async def main(): async with engine.connect() as conn: rows = (await conn.execute(text( "SELECT id, username, real_name, hashed_password, email, email_verified," " phone, phone_verified, is_active FROM users" " WHERE account_user_id IS NULL" ))).mappings().all() print(json.dumps([{ "id": str(r["id"]), "username": r["username"], "hashed_password": r["hashed_password"], # bcrypt 原样透传,用户密码不变 "real_name": r["real_name"] or r["username"], "email": r["email"], "email_verified": r["email_verified"], "phone": r["phone"], "phone_verified": r["phone_verified"], "is_active": r["is_active"], } for r in rows], ensure_ascii=False)) asyncio.run(main()) PY导入 account(保 UUID + bcrypt 原样、幂等可重跑;
username_taken冲突说明该账号已在 account 存在,核对后跳过即可):bashkubectl -n veyraos cp staff_dump.json deploy/account:/tmp/staff_dump.json kubectl -n veyraos exec deploy/account -- \ python -m app.manage import-staff /tmp/staff_dump.json若 account 由外部团队托管(部署在对方集群,非本集群
deploy/account),把上一步导出的staff_dump.json经安全渠道交给对方,由对方在其集群执行import-staff;导入保 UUID,故第 3 步回填的 id 仍与本集群一致。回填关联(
import-staff保留原 UUID,account 侧用户 id 与 manager 侧一致;逐行核对后再执行 UPDATE):sql-- 先核对:account 库确认每个 id 都存在 -- SELECT id, username FROM users WHERE id IN (...); -- 在 account 库执行 UPDATE users SET account_user_id = id WHERE account_user_id IS NULL AND id IN (<上一步成功导入的 id 列表>);为什么必须回填而不是靠首次登录自动建档
import-staff导入的账号没有组织归属(注册用户才有的个人空间),未回填的账号走 SSO 首次登录会被拒(提示服务暂不可用)。回填后按关联直接登录,不经过建档流程。升级与收尾:执行 migration
072_sso_login_states.sql/073_sso_nonce.sql(SSO 未上线环境补跑)与085_users_session_invalid_before.sql(会话撤销水位,统一登出依赖),升级 manager / console-admin 镜像,用任一存量账号走一遍「访问管理台 → 跳转账号中心登录 → 回跳进入」验证,再验证「账号中心登出 → 管理台刷新已登出」。staff_dump.json含密码哈希,用完即删。
部署配置
一键部署(deploy.sh)在 DEPLOY_PLATFORM=true 时自动创建 account 域 ingress(仅上表公网端点)。需在 deploy/ci/.env.local 补充两个新密钥:
# 会话 cookie 签名密钥(与 JWT secret 相互独立)
ACCOUNT_SSO_COOKIE_KEY=$(openssl rand -base64 48)
# OIDC RS256 私钥(PEM 转单行,换行写成 \n 字面量)
ACCOUNT_SSO_OIDC_PRIVATE_KEY=$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null \
| awk '{printf "%s\\n", $0}')两者经 veyraos-secret 注入 account Pod。任一缺失时服务启动会因安全校验直接失败(fail-closed,不允许默认密钥上线);补齐后重启 Pod 即恢复。
另有一项非密钥配置:ACCOUNT_LOGOUT_REDIRECT_URL(统一登出链终落地地址,如主站首页 https://www.<域名>;清单已内置占位值,留空则回落 /loggedout 落地页)。
Router 独立生产栈(deploy/router-prod/,已迁 veyra-router 仓)同样部署 account 作 IdP:独立 namespace veyraos(与 router 面隔离),清单 account.yaml + account-ingress.yaml(公网面同样仅上表端点),密钥走 account-secret(键清单与生成命令见 deploy/router-prod/secret.example.yaml),域名经 apply.sh 占位符注入;console RP 的注册命令见该目录 README。
会话语义:服务端会话最长 7 天(到期需重新输密码);浏览器关闭后会话 cookie 即失效,重开浏览器访问任一接入服务会重新到登录页输一次密码。重置密码会使所有已登录会话立即失效。
Console(Router 控制台)本地会话角色在登录兑换时按 account 的 platform_staff 绑定判定:scopes 覆盖 router:operator → operator(运营面),无绑定 / 查询失败回落 caller(自助面),运营授权的撤销即时生效。
员工授权(platform_staff)
员工不是独立账号——account 注册用户 + 一条 staff 绑定 = 平台员工(#480 §二)。新部署的初始账号没有员工属性,首个员工(鸡生蛋问题)经一次性 CLI 自提:
管理台平台超管同口径:manager 的
users.is_platform_admin只是本地投影,判定以本表为准——status=active且 scopes 含staff:manage或saas:ops。登录 / SSO / JIT / 刷新 /GET /me从 BSS 同步,已链接账号按短 TTL 复验,撤销或停用会即时降权(BSS 不可达时保留本地值)。全新安装由安装完成后的首次引导设置管理员账号(keeper 不再预置初始管理员),绑定关系同步落平台员工;云上与存量环境用下方promote-staff手工补授权。
# 前置:该邮箱已在 account.<域名> 正常注册;幂等可重复执行
kubectl -n veyraos exec deploy/account -c account -- \
python -m app.manage promote-staff <邮箱> --scopes staff:manage,saas:ops,router:operator全新环境冷启动引导
全新环境没有用户、没有邮件通道,「注册要验证码 → 验证码要通道 → 配通道要员工登录 → 员工要注册」会死循环。破局点是部署期 env 邮件兜底(发送选路的末位虚拟通道,DB 无通道时自动生效):
部署时给 account 配 env 邮件兜底(
deploy-platform.yaml已留注释组,解开填入真实值)。密码必须入 k8s Secret,不得明文进 shell 历史或 Deployment 清单:bash# ① 密码入 Secret(幂等,key 名即环境变量名,供 --from=secret 直接导入) kubectl -n veyraos create secret generic smtp-creds \ --from-literal=ACCOUNT_SMTP_PASSWORD='<密码>' \ --dry-run=client -o yaml | kubectl apply -f - # ② 其余非敏感项 + 从 Secret 导入密码(env 变更自动触发滚动重启) kubectl -n veyraos set env deploy/account \ ACCOUNT_EMAIL_PROVIDER=smtp \ ACCOUNT_SMTP_HOST=smtp.example.com \ ACCOUNT_SMTP_PORT=465 \ ACCOUNT_SMTP_USER=noreply@example.com \ --from=secret/smtp-creds \ ACCOUNT_SMTP_FROM=noreply@example.com- 云厂商发信(aliyun/tencent/huawei)改用
ACCOUNT_EMAIL_ACCESS_KEY_*组,AK/SK 同样入 Secret,见services/account/app/config.py; ACCOUNT_EMAIL_TEST_RECIPIENT联调白名单生产必须留空——非空时仅白名单内目标能收到邮件,注册验证码会被拦。
- 云厂商发信(aliyun/tencent/huawei)改用
注册首个账号:在
account.<域名>用邮箱正常注册,验证码经 env 兜底通道送达。提权首个员工:用上面的
promote-staffCLI。登录运营门户配置正式通道:该员工 SSO 登录
platform.<域名>→「渠道配置」录入 DB 邮件通道(凭据加密入库,为主路径)。收尾:env 兜底自动退居末位备份,可保留也可移除;后续通道变更一律走运营门户,不再动部署配置。
scope 目录(services/account/app/staff_scopes.py):
| scope | 含义 |
|---|---|
staff:manage | 员工管理(运营门户「员工管理」页 + OAuth client 管理) |
saas:ops | SaaS 运营全集(细分 customers / credit / invoice / risk / channels / payments) |
router:operator | Router 控制台运营面 |
首个员工自提后,加人/授权/回收/停用一律走运营门户「员工管理」页(需 staff:manage),变更落 account 审计;每请求查 staff 表校验,撤销即时生效。CLI 与 env 不做常驻提权通道(#480 拍板 f:一次性动作天然可审计)。
验收清单
- 首次登录:接入方未登录 → 登录页 → 回调 → 进入业务页
- 免密:已登录后访问第二个接入方,全程零交互
- 重开浏览器:任一接入方触发重新输密码
- 授权码单次有效:重复兑换被拒;篡改回调地址 / state / PKCE 均被拒
- 统一登出:各接入方本地会话全部清除,链终落主站(未配置
ACCOUNT_LOGOUT_REDIRECT_URL时为已登出落地页) https://account.<域名>/oauth/token公网不可达;JWKS 公网可读