Skip to content

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/returntoSSO 回跳恢复(门户登录成功后续走 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 应不可达)。

接入方接入步骤 ​

  1. 部署即注册: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 单独注册(幂等,可重复执行更新):
    bash
    kubectl -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),新增回调需再次注册。
  2. 接入方按 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),无需门户侧感知接入方。

  3. 登出:接入方提供单一「退出」入口,默认跳 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,scope openid 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 收口版本时,必须先完成本迁移再升级管理台——新版本登录页不再有本地表单,未迁移的账号将完全无法登录。全程约一个登录不可用窗口,建议在低峰期执行。

  1. 导出存量用户(在 manager 数据库上执行,生成 staff_dump.json):

    bash
    kubectl -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
  2. 导入 account(保 UUID + bcrypt 原样、幂等可重跑;username_taken 冲突说明该账号已在 account 存在,核对后跳过即可):

    bash
    kubectl -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 仍与本集群一致。

  3. 回填关联(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 首次登录会被拒(提示服务暂不可用)。回填后按关联直接登录,不经过建档流程。

  4. 升级与收尾:执行 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 补充两个新密钥:

bash
# 会话 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 手工补授权。

bash
# 前置:该邮箱已在 account.<域名> 正常注册;幂等可重复执行
kubectl -n veyraos exec deploy/account -c account -- \
  python -m app.manage promote-staff <邮箱> --scopes staff:manage,saas:ops,router:operator

全新环境冷启动引导 ​

全新环境没有用户、没有邮件通道,「注册要验证码 → 验证码要通道 → 配通道要员工登录 → 员工要注册」会死循环。破局点是部署期 env 邮件兜底(发送选路的末位虚拟通道,DB 无通道时自动生效):

  1. 部署时给 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 联调白名单生产必须留空——非空时仅白名单内目标能收到邮件,注册验证码会被拦。
  2. 注册首个账号:在 account.<域名> 用邮箱正常注册,验证码经 env 兜底通道送达。

  3. 提权首个员工:用上面的 promote-staff CLI。

  4. 登录运营门户配置正式通道:该员工 SSO 登录 platform.<域名> →「渠道配置」录入 DB 邮件通道(凭据加密入库,为主路径)。

  5. 收尾:env 兜底自动退居末位备份,可保留也可移除;后续通道变更一律走运营门户,不再动部署配置。

scope 目录(services/account/app/staff_scopes.py):

scope含义
staff:manage员工管理(运营门户「员工管理」页 + OAuth client 管理)
saas:opsSaaS 运营全集(细分 customers / credit / invoice / risk / channels / payments)
router:operatorRouter 控制台运营面

首个员工自提后,加人/授权/回收/停用一律走运营门户「员工管理」页(需 staff:manage),变更落 account 审计;每请求查 staff 表校验,撤销即时生效。CLI 与 env 不做常驻提权通道(#480 拍板 f:一次性动作天然可审计)。

验收清单 ​

  • 首次登录:接入方未登录 → 登录页 → 回调 → 进入业务页
  • 免密:已登录后访问第二个接入方,全程零交互
  • 重开浏览器:任一接入方触发重新输密码
  • 授权码单次有效:重复兑换被拒;篡改回调地址 / state / PKCE 均被拒
  • 统一登出:各接入方本地会话全部清除,链终落主站(未配置 ACCOUNT_LOGOUT_REDIRECT_URL 时为已登出落地页)
  • https://account.<域名>/oauth/token 公网不可达;JWKS 公网可读

基于内网部署的企业级 AI 智能体平台