Spring Boot 接入 MaxKey 单点登录:6 个内部系统,一次登录全通行

技术栈版本: MaxKey 4.1.11 GA | Spring Boot 3.4.x | Java 21 | Vue 3.5.x | MySQL 8.4 | Redis 7.x | 更新时间: 2026-06

公司内部有 6 套系统:OA、HR、项目管理、知识库、CRM、运维监控,每套都有自己的账号体系和登录页。员工一天要在 6 个页面反复输密码,密码重置工单一度占到 IT 工单的四分之一。我们用开源的 MaxKey 搭了一套统一认证,一次登录 6 个系统全通行,密码工单少了 85%。本文记录从选型、Docker 部署、OIDC 配置到 Spring Boot / Vue3 集成的完整过程,以及 5 个我们真实踩过的坑。

一、场景:6 个登录页的"密码疲劳"

公司这 6 套系统是不同时期、不同团队建的,账号各管各的。新员工入职要开 6 个账号,离职要逐一禁用,漏一个就是安全隐患。

痛点量化数据

指标 改造前 改造后 提升幅度
人均日登录次数 8 次 1 次(SSO 一次通行) 降低 87.5%
密码重置工单/月 300+ 45 以内 降低 85%
离职账号回收 1-2 天(各系统逐一禁用) 实时禁用(MaxKey 统一管控) 降低 99%
新系统接入认证 3 人天(从零写登录逻辑) 0.5 人天(标准 OIDC 接入) 降低 83%
弱密码/明文传输 各系统标准不一 统一密码策略 + HTTPS 强制 安全闭环

每套系统都在重复造"用户表 + 登录页 + 密码加密 + Session 管理"的轮子,开发费一遍功夫,安全上还各留各的口子。

二、开源 IAM 选型:MaxKey vs Keycloak vs Casdoor

2.1 三大方案深度对比

三个方案都是 Apache 2.0 协议,但技术栈和国内适配差别不小,选错了再迁移代价很高。我们当时主要看了这几点:

维度 MaxKey 4.1.11 Keycloak 26.x Casdoor
技术栈 Java / Spring Boot Java / Quarkus Go
协议支持 OAuth2 / OIDC / SAML 2.0 / CAS / JWT / SCIM OAuth2 / OIDC / SAML 2.0 / CAS OAuth2 / OIDC / SAML 2.0 / CAS
国内身份源 钉钉 / 企微 / 飞书 / AD / LDAP(原生) 需自写 SPI 插件 钉钉 / 企微 / 飞书(原生)
管理界面 中文原生 英文(需社区汉化包) 中英双语
部署复杂度 Docker 一键 / 单机即可 依赖 PostgreSQL + Infinispan Docker 一键
授权模型 RBAC + ACL RBAC RBAC + ABAC + ReBAC(Casbin)
多租户 支持 支持 原生支持
社区 国内微信群 + Gitee(1.7K Star) 全球社区 + GitHub(24K+ Star) GitHub(9K+ Star)
适用场景 国内企业私有化部署 全球化企业 需精细授权策略

2.2 成本对比

维度 MaxKey(私有化) Keycloak(私有化) 阿里云 IDaaS(云服务)
服务器 2 核 4G ×2 ≈ 300 元/月 4 核 8G ×2 ≈ 600 元/月 0(含在服务费中)
数据库 RDS MySQL 基础版 ≈ 200 元/月 RDS PostgreSQL ≈ 250 元/月 0
运维人力 0.2 人(兼职) 0.5 人 0
授权费 0(开源) 0(开源) 企业版 8,640 元/月起
年总成本 ≈ 6,000 元 ≈ 10,200 元 ≈ 103,680 元

2.3 选型结论

我们的情况是国内企业、必须私有化、身份源以钉钉为主,MaxKey 三条全中,最后选了它。几种典型情况分别对应:

  • 国内企业 + 私有化 + 钉钉/企微/飞书 → MaxKey(中文原生、国内身份源开箱即用)
  • 全球化团队 + SAML 深度需求 → Keycloak(协议最全、社区最大)
  • 精细授权策略 + Go 技术栈 → Casdoor(Casbin 集成、ReBAC 原生支持)
  • 不想运维 + 云服务 → 阿里云 IDaaS(零运维、按需付费)

另外补一句选 MaxKey 时看过的背景:这项目从 2016 年的 v1.0 GA 一路迭代到现在,社区版一直在更新维护:

MaxKey 版本发展历史(官方文档),社区版从 2016 年持续迭代至今

三、MaxKey 架构与 Docker 部署

3.1 系统架构

#mermaid-svg-rYrcLRlCxwghkiZC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rYrcLRlCxwghkiZC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rYrcLRlCxwghkiZC .error-icon{fill:#552222;}#mermaid-svg-rYrcLRlCxwghkiZC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rYrcLRlCxwghkiZC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rYrcLRlCxwghkiZC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rYrcLRlCxwghkiZC .marker.cross{stroke:#333333;}#mermaid-svg-rYrcLRlCxwghkiZC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rYrcLRlCxwghkiZC p{margin:0;}#mermaid-svg-rYrcLRlCxwghkiZC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rYrcLRlCxwghkiZC .cluster-label text{fill:#333;}#mermaid-svg-rYrcLRlCxwghkiZC .cluster-label span{color:#333;}#mermaid-svg-rYrcLRlCxwghkiZC .cluster-label span p{background-color:transparent;}#mermaid-svg-rYrcLRlCxwghkiZC .label text,#mermaid-svg-rYrcLRlCxwghkiZC span{fill:#333;color:#333;}#mermaid-svg-rYrcLRlCxwghkiZC .node rect,#mermaid-svg-rYrcLRlCxwghkiZC .node circle,#mermaid-svg-rYrcLRlCxwghkiZC .node ellipse,#mermaid-svg-rYrcLRlCxwghkiZC .node polygon,#mermaid-svg-rYrcLRlCxwghkiZC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rYrcLRlCxwghkiZC .rough-node .label text,#mermaid-svg-rYrcLRlCxwghkiZC .node .label text,#mermaid-svg-rYrcLRlCxwghkiZC .image-shape .label,#mermaid-svg-rYrcLRlCxwghkiZC .icon-shape .label{text-anchor:middle;}#mermaid-svg-rYrcLRlCxwghkiZC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rYrcLRlCxwghkiZC .rough-node .label,#mermaid-svg-rYrcLRlCxwghkiZC .node .label,#mermaid-svg-rYrcLRlCxwghkiZC .image-shape .label,#mermaid-svg-rYrcLRlCxwghkiZC .icon-shape .label{text-align:center;}#mermaid-svg-rYrcLRlCxwghkiZC .node.clickable{cursor:pointer;}#mermaid-svg-rYrcLRlCxwghkiZC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rYrcLRlCxwghkiZC .arrowheadPath{fill:#333333;}#mermaid-svg-rYrcLRlCxwghkiZC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rYrcLRlCxwghkiZC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rYrcLRlCxwghkiZC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rYrcLRlCxwghkiZC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rYrcLRlCxwghkiZC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rYrcLRlCxwghkiZC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rYrcLRlCxwghkiZC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rYrcLRlCxwghkiZC .cluster text{fill:#333;}#mermaid-svg-rYrcLRlCxwghkiZC .cluster span{color:#333;}#mermaid-svg-rYrcLRlCxwghkiZC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rYrcLRlCxwghkiZC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rYrcLRlCxwghkiZC rect.text{fill:none;stroke-width:0;}#mermaid-svg-rYrcLRlCxwghkiZC .icon-shape,#mermaid-svg-rYrcLRlCxwghkiZC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rYrcLRlCxwghkiZC .icon-shape p,#mermaid-svg-rYrcLRlCxwghkiZC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rYrcLRlCxwghkiZC .icon-shape .label rect,#mermaid-svg-rYrcLRlCxwghkiZC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rYrcLRlCxwghkiZC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rYrcLRlCxwghkiZC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rYrcLRlCxwghkiZC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务系统层
身份源层
存储层
MaxKey 认证中心
用户层
浏览器

Vue3 SPA
移动端

App / 小程序
认证服务

:9527
管理服务

:9526
用户前端

:8527
管理前端

:8526
MySQL 8.4

用户/应用/策略
Redis 7.x

Session/Token 缓存
钉钉
企业微信
AD / LDAP
OA 系统

Spring Boot 3.4
HR 系统

Spring Boot 3.4
项目管理

Spring Boot 3.4

3.2 Docker Compose 部署

MaxKey 的安装文档和部署脚本都在官网 maxkey.top 上:

MaxKey 官网首页(maxkey.top),文档、安装包和社区入口都在这里

全套组件用 Docker Compose 一键就能拉起来,生产环境再迁 K8s 也不冲突。建议直接用官方部署脚本,不要手动拼配置------我们手拼过一次,漏了一个环境变量,排查了半天。

bash 复制代码
# 克隆官方部署文件(MaxKey 4.1.11)
git clone https://gitee.com/dromara/MaxKey.git
cd MaxKey/deployment/docker

# 拉取镜像
chmod +x maxkey_docker_install.sh
./maxkey_docker_install.sh

# 启动全部服务
./maxkey_docker_start.sh

# 验证服务是否就绪
docker-compose ps
curl http://localhost/maxkey/health
# 返回 {"status":"UP"} 表示部署成功

Docker Compose 关键配置(基于官方脚本,适配生产环境):

官方脚本默认配置适合开发环境,生产环境需要增加 MySQL 健康检查、Redis 持久化、时区设置。

yaml 复制代码
# docker-compose.yml(生产适配版)
version: '3.8'
services:
  mysql:
    image: mysql:8.4.2
    container_name: maxkey-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      TZ: Asia/Shanghai
    ports:
      - "3306:3306"
    volumes:
      - ./mysql-data:/var/lib/mysql:rw
      - ./mysql-conf:/etc/mysql/conf.d:ro
    healthcheck: # 健康检查:确保 MySQL 完全就绪
      test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - maxkey.top

  redis:
    image: redis:7-alpine
    container_name: maxkey-redis
    command: redis-server --appendonly yes  # 开启 AOF 持久化
    volumes:
      - ./redis-data:/data
    networks:
      - maxkey.top

  maxkey:
    image: maxkeytop/maxkey:4.1.11
    container_name: maxkey
    environment:
      - DATABASE_HOST=mysql
      - DATABASE_PORT=3306
      - DATABASE_NAME=maxkey
      - DATABASE_USER=root
      - DATABASE_PWD=${MYSQL_ROOT_PASSWORD}
      - REDIS_HOST=redis
      - TZ=Asia/Shanghai
    ports:
      - "9527:9527"
    depends_on:
      mysql:
        condition: service_healthy    # 等 MySQL 健康检查通过再启动
      redis:
        condition: service_started
    networks:
      - maxkey.top

  maxkey-mgt:
    image: maxkeytop/maxkey-mgt:4.1.11
    container_name: maxkey-mgt
    ports:
      - "9526:9526"
    depends_on:
      - maxkey
    networks:
      - maxkey.top

  maxkey-frontend:
    image: maxkeytop/maxkey-frontend:4.1.11
    container_name: maxkey-frontend
    ports:
      - "8527:8527"
    networks:
      - maxkey.top

  maxkey-mgt-frontend:
    image: maxkeytop/maxkey-mgt-frontend:4.1.11
    container_name: maxkey-mgt-frontend
    ports:
      - "8526:8526"
    networks:
      - maxkey.top

networks:
  maxkey.top:
    driver: bridge

3.3 管理端首次配置

部署成功后访问管理端:

入口 地址 默认账号
管理前端 http://服务器IP:8526 admin / maxkey
用户前端 http://服务器IP:8527 ---
认证服务 http://服务器IP:9527 ---

安全提醒:首次登录后必须修改默认密码!密码策略:至少 8 位,含大小写字母 + 数字 + 特殊符号。

四、MaxKey OIDC 应用配置

4.1 创建应用(OIDC 协议)

在 MaxKey 管理端(8526 端口)创建应用:

  1. 进入 应用管理 → 应用注册
  2. 填写应用基本信息:
配置项 说明
应用名称 OA 系统 业务系统名称
访问协议 OAuth 2.0 / OpenID Connect 推荐使用 OIDC
Client ID 自动生成 9cdbccbe-47a0-4adb-9d3d-7e0eceacaace
Client Secret 自动生成 妥善保管,不可泄露
登录地址 https://oa.company.com 业务系统入口
认证地址(回调) https://oa.company.com/login/oauth2/code/maxkey OIDC 回调 URL
授权方式 authorization_code 授权码模式,最安全
Token 签名算法 RS256 非对称签名,推荐

重要 :回调地址必须与实际请求的 URL 精确匹配(包括协议、域名、端口),否则会报 redirect_uri_mismatch 错误。

4.2 对接身份源(钉钉)

我们的用户主数据在钉钉上。MaxKey 原生支持钉钉身份源,组织架构同步不用自己写一行代码:

  1. 在钉钉开放平台创建企业内部应用,获取 AppKeyAppSecret
  2. 在 MaxKey 管理端进入 身份源管理 → 新增 → 钉钉
  3. 填入 AppKey / AppSecret,配置同步策略
  4. 启用后,钉钉组织架构自动同步到 MaxKey 用户体系

4.3 登录策略与 MFA

策略 推荐配置 说明
密码策略 8 位以上 + 大小写 + 数字 + 特殊符号 防止弱密码
会话超时 8 小时(工作日) 平衡安全与体验
TOTP 双因素 管理员账号强制开启 防止账号被盗
IP 白名单 管理端仅限内网访问 减少攻击面

五、Spring Boot 3.x 后端集成

5.1 OIDC 授权码模式认证流程

先把 OIDC 授权码模式的完整链路走一遍,后面配 Spring Security 出了问题,才知道卡在哪一步。
Spring Boot 后端 MaxKey 认证中心 Vue3 应用 浏览器 Spring Boot 后端 MaxKey 认证中心 Vue3 应用 浏览器 #mermaid-svg-lsteYJsLwm4DtJN1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lsteYJsLwm4DtJN1 .error-icon{fill:#552222;}#mermaid-svg-lsteYJsLwm4DtJN1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lsteYJsLwm4DtJN1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lsteYJsLwm4DtJN1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lsteYJsLwm4DtJN1 .marker.cross{stroke:#333333;}#mermaid-svg-lsteYJsLwm4DtJN1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lsteYJsLwm4DtJN1 p{margin:0;}#mermaid-svg-lsteYJsLwm4DtJN1 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lsteYJsLwm4DtJN1 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lsteYJsLwm4DtJN1 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lsteYJsLwm4DtJN1 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lsteYJsLwm4DtJN1 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lsteYJsLwm4DtJN1 .sequenceNumber{fill:white;}#mermaid-svg-lsteYJsLwm4DtJN1 #sequencenumber{fill:#333;}#mermaid-svg-lsteYJsLwm4DtJN1 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lsteYJsLwm4DtJN1 .messageText{fill:#333;stroke:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lsteYJsLwm4DtJN1 .labelText,#mermaid-svg-lsteYJsLwm4DtJN1 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .loopText,#mermaid-svg-lsteYJsLwm4DtJN1 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lsteYJsLwm4DtJN1 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lsteYJsLwm4DtJN1 .noteText,#mermaid-svg-lsteYJsLwm4DtJN1 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lsteYJsLwm4DtJN1 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lsteYJsLwm4DtJN1 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lsteYJsLwm4DtJN1 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lsteYJsLwm4DtJN1 .actorPopupMenu{position:absolute;}#mermaid-svg-lsteYJsLwm4DtJN1 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-lsteYJsLwm4DtJN1 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lsteYJsLwm4DtJN1 .actor-man circle,#mermaid-svg-lsteYJsLwm4DtJN1 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lsteYJsLwm4DtJN1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 访问受保护页面 2. 请求 API(无 Token) 3. 401 Unauthorized 4. 重定向到 /oauth2/authorization/maxkey 5. GET /sign/authz/oauth/v20/authorize?client_id=xxx&redirect_uri=xxx 6. 展示登录页(如未登录) 7. 提交用户名/密码 8. 302 重定向到 redirect_uri?code=AUTH_CODE 9. GET /login/oauth2/code/maxkey?code=AUTH_CODE 10. POST /sign/authz/oauth/v20/token(用 code 换 token) 11. 返回 access_token + id_token 12. GET /sign/api/oauth/v20/me(获取用户信息) 13. 返回用户属性 14. 构建 SecurityContext + 生成 Session 15. 302 重定向到原始页面(已认证)

5.2 Maven 依赖

两个依赖就够:spring-boot-starter-oauth2-client 自带 OIDC Client,nimbus-jose-jwt 负责 JWT 验签,不用自己碰加密。

xml 复制代码
<!-- pom.xml 关键依赖(Spring Boot 3.4.x + Java 21) -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
  <!-- JWT 公钥验签 -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.3</version>
</dependency>

5.3 YAML 配置

这里有个容易漏的点:MaxKey 的 OIDC 端点路径和 Spring Boot 默认约定不一样,authorization-uritoken-uriuser-info-uri 都要显式指定,照抄 Keycloak 的配置是通不了的。

yaml 复制代码
# application.yml(MaxKey OIDC 配置)
spring:
  security:
    oauth2:
      client:
        registration:
          maxkey: # registrationId
            client-id: 9cdbccbe-47a0-4adb-9d3d-7e0eceacaace
            client-secret: F3QOMTUwMzIwMjExMTMyMTAzNDknMW
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - openid
              - profile
              - email
            client-name: MaxKey SSO
        provider:
          maxkey:
            authorization-uri: https://sso.company.com/sign/authz/oauth/v20/authorize
            token-uri: https://sso.company.com/sign/authz/oauth/v20/token
            user-info-uri: https://sso.company.com/sign/api/oauth/v20/me
            user-name-attribute: username      # MaxKey 返回的用户名属性名

5.4 SecurityFilterChain 配置

Spring Security 6.x 只能用 Lambda DSL 了,链式 API 已废弃。oauth2Login 里把 MaxKey 配成 Identity Provider:

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**", "/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/")
                .defaultSuccessUrl("/dashboard", true)
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(maxKeyOAuth2UserService())  // 自定义用户信息处理
                )
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .clearAuthentication(true)
            )
            .csrf(csrf -> csrf.disable());  // 前后端分离场景由 Token 保护

        return http.build();
    }

    @Bean
    public OAuth2UserService<OAuth2UserRequest, OAuth2User> maxKeyOAuth2UserService() {
        return new MaxKeyOAuth2UserService();
    }
}

5.5 MaxKey 用户到本地用户的映射

MaxKey 返回的属性名和业务系统往往对不上(它叫 username,我们的用户表叫 account),所以在自定义 UserService 里做一层映射,把"MaxKey 用户 → Spring 角色 → 业务权限"这条链打通:

java 复制代码
@Service
public class MaxKeyOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest);

        // 1. 从 MaxKey 属性映射到业务字段
        String username = oauth2User.getAttribute("username");
        String displayName = oauth2User.getAttribute("displayName");
        String email = oauth2User.getAttribute("email");
        String mobile = oauth2User.getAttribute("mobile");

        // 2. 同步到本地用户表(首次自动注册,后续更新)
        User localUser = userRepository.findByUsername(username)
            .orElseGet(() -> createLocalUser(username, displayName, email, mobile));

        // 3. MaxKey 组 → Spring 角色 映射
        Set<GrantedAuthority> authorities = mapMaxKeyGroupsToRoles(oauth2User);

        return new DefaultOAuth2User(
            authorities,
            oauth2User.getAttributes(),
            "username"   // userNameAttributeName
        );
    }

    private Set<GrantedAuthority> mapMaxKeyGroupsToRoles(OAuth2User oauth2User) {
        // MaxKey 返回的 groups 属性映射为 Spring Security 角色
        List<String> groups = oauth2User.getAttribute("groups");
        if (groups == null) return Set.of();

        return groups.stream()
            .map(group -> switch (group) {
                case "OA_ADMIN" -> new SimpleGrantedAuthority("ROLE_ADMIN");
                case "OA_USER"  -> new SimpleGrantedAuthority("ROLE_USER");
                default         -> new SimpleGrantedAuthority("ROLE_GUEST");
            })
            .collect(Collectors.toSet());
    }
}

5.6 JWT Token 验证流程

前后端分离架构下,前端拿着 Access Token 调 API,后端要自己验 Token。MaxKey 用 RS256 非对称签名,后端拿 JWKS 公钥在本地验签就行,不用每次请求都去问 MaxKey。
#mermaid-svg-Z5dgTaLDas8DVIHo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Z5dgTaLDas8DVIHo .error-icon{fill:#552222;}#mermaid-svg-Z5dgTaLDas8DVIHo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Z5dgTaLDas8DVIHo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Z5dgTaLDas8DVIHo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Z5dgTaLDas8DVIHo .marker.cross{stroke:#333333;}#mermaid-svg-Z5dgTaLDas8DVIHo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Z5dgTaLDas8DVIHo p{margin:0;}#mermaid-svg-Z5dgTaLDas8DVIHo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster-label text{fill:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster-label span{color:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster-label span p{background-color:transparent;}#mermaid-svg-Z5dgTaLDas8DVIHo .label text,#mermaid-svg-Z5dgTaLDas8DVIHo span{fill:#333;color:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo .node rect,#mermaid-svg-Z5dgTaLDas8DVIHo .node circle,#mermaid-svg-Z5dgTaLDas8DVIHo .node ellipse,#mermaid-svg-Z5dgTaLDas8DVIHo .node polygon,#mermaid-svg-Z5dgTaLDas8DVIHo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Z5dgTaLDas8DVIHo .rough-node .label text,#mermaid-svg-Z5dgTaLDas8DVIHo .node .label text,#mermaid-svg-Z5dgTaLDas8DVIHo .image-shape .label,#mermaid-svg-Z5dgTaLDas8DVIHo .icon-shape .label{text-anchor:middle;}#mermaid-svg-Z5dgTaLDas8DVIHo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Z5dgTaLDas8DVIHo .rough-node .label,#mermaid-svg-Z5dgTaLDas8DVIHo .node .label,#mermaid-svg-Z5dgTaLDas8DVIHo .image-shape .label,#mermaid-svg-Z5dgTaLDas8DVIHo .icon-shape .label{text-align:center;}#mermaid-svg-Z5dgTaLDas8DVIHo .node.clickable{cursor:pointer;}#mermaid-svg-Z5dgTaLDas8DVIHo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Z5dgTaLDas8DVIHo .arrowheadPath{fill:#333333;}#mermaid-svg-Z5dgTaLDas8DVIHo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Z5dgTaLDas8DVIHo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Z5dgTaLDas8DVIHo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z5dgTaLDas8DVIHo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Z5dgTaLDas8DVIHo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z5dgTaLDas8DVIHo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster text{fill:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo .cluster span{color:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Z5dgTaLDas8DVIHo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Z5dgTaLDas8DVIHo rect.text{fill:none;stroke-width:0;}#mermaid-svg-Z5dgTaLDas8DVIHo .icon-shape,#mermaid-svg-Z5dgTaLDas8DVIHo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z5dgTaLDas8DVIHo .icon-shape p,#mermaid-svg-Z5dgTaLDas8DVIHo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Z5dgTaLDas8DVIHo .icon-shape .label rect,#mermaid-svg-Z5dgTaLDas8DVIHo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z5dgTaLDas8DVIHo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Z5dgTaLDas8DVIHo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Z5dgTaLDas8DVIHo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是









请求到达

Authorization: Bearer xxx
提取 JWT Token
解析 Header

获取 kid
本地公钥缓存

是否存在 kid?
用缓存的公钥验签
请求 MaxKey JWKS 端点

/sign/authz/oidc/jwks
缓存公钥

TTL = 1 小时
验签通过?
返回 401 Unauthorized
校验 Claims
iss == MaxKey?
aud == 本应用?
exp > 当前时间?
构建 SecurityContext

放行请求

java 复制代码
@Component
public class JwtTokenValidator extends OncePerRequestFilter {

    private final JWKSource<SecurityContext> jwkSource;
    private final Map<String, RSAPublicKey> keyCache = new ConcurrentHashMap<>();
    private volatile long lastKeyRefresh = 0;
    private static final long KEY_REFRESH_INTERVAL = 3600_000; // 1 小时

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = extractToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // 1. 解析 JWT Header 获取 kid
            SignedJWT jwt = SignedJWT.parse(token);
            String kid = jwt.getHeader().getKeyID();

            // 2. 获取公钥(缓存优先,过期刷新)
            RSAPublicKey publicKey = getPublicKey(kid);

            // 3. 验签
            JWSVerifier verifier = new RSASSAVerifier(publicKey);
            if (!jwt.verify(verifier)) {
                sendError(response, 401, "Invalid token signature");
                return;
            }

            // 4. 校验 Claims
            JWTClaimsSet claims = jwt.getJWTClaimsSet();
            if (claims.getExpirationTime().before(new Date())) {
                sendError(response, 401, "Token expired");
                return;
            }

            // 5. 构建 SecurityContext
            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null, authorities(claims));
            SecurityContextHolder.getContext().setAuthentication(auth);

        } catch (Exception e) {
            sendError(response, 401, "Token validation failed: " + e.getMessage());
            return;
        }

        filterChain.doFilter(request, response);
    }

    private RSAPublicKey getPublicKey(String kid) throws Exception {
        // 缓存命中且未过期
        if (keyCache.containsKey(kid)
                && System.currentTimeMillis() - lastKeyRefresh < KEY_REFRESH_INTERVAL) {
            return keyCache.get(kid);
        }
        // 强制刷新 JWKS
        refreshKeys();
        RSAPublicKey key = keyCache.get(kid);
        if (key == null) {
            throw new SecurityException("Unknown key ID: " + kid);
        }
        return key;
    }
}

六、Vue3 前端集成

6.1 登录跳转与回调处理

先说一个原则:Client Secret 绝不能放到前端,授权码换 Token 必须走后端,这是 OIDC 的安全要求。前端只负责发现"还没登录",然后发起跳转。

typescript 复制代码
// src/utils/auth.ts
const MAXKEY_LOGIN_URL = 'https://sso.company.com/sign/authz/oauth/v20/authorize'
const CLIENT_ID = '9cdbccbe-47a0-4adb-9d3d-7e0eceacaace'
const REDIRECT_URI = `${window.location.origin}/auth/callback`

/**
 * 触发 SSO 登录跳转
 * 当后端 API 返回 401 时调用此函数
 */
export function redirectToSSO() {
  const state = crypto.randomUUID()  // 防 CSRF
  sessionStorage.setItem('oauth_state', state)
  sessionStorage.setItem('redirect_after_login', window.location.pathname)

  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    response_type: 'code',
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    state
  })

  window.location.href = `${MAXKEY_LOGIN_URL}?${params.toString()}`
}

/**
 * OIDC 回调处理
 * 回调 URL: /auth/callback?code=xxx&state=xxx
 */
export async function handleOAuthCallback(code: string, state: string) {
  // 1. 验证 state 防 CSRF
  const savedState = sessionStorage.getItem('oauth_state')
  if (state !== savedState) {
    throw new Error('Invalid OAuth state - possible CSRF attack')
  }

  // 2. 用 code 换 token(通过后端接口,不暴露 client_secret)
  const response = await fetch('/api/auth/token', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({code, redirect_uri: REDIRECT_URI})
  })

  const {access_token, refresh_token, expires_in} = await response.json()

  // 3. 存储 Token
  const authStore = useAuthStore()
  authStore.setTokens(access_token, refresh_token, expires_in)

  // 4. 跳转到登录前的页面
  const redirect = sessionStorage.getItem('redirect_after_login') || '/dashboard'
  sessionStorage.removeItem('oauth_state')
  sessionStorage.removeItem('redirect_after_login')
  return redirect
}

6.2 Pinia Token 状态管理

Access Token 一般 2 小时就过期,不做自动刷新,用户表单填到一半就掉线了。我们的做法是提前 5 分钟静默刷新。

typescript 复制代码
// src/stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref('')
  const refreshToken = ref('')
  const expiresAt = ref(0)

  /** 设置 Token(登录/刷新后调用) */
  function setTokens(access: string, refresh: string, expiresIn: number) {
    accessToken.value = access
    refreshToken.value = refresh
    expiresAt.value = Date.now() + expiresIn * 1000
    localStorage.setItem('auth_tokens', JSON.stringify({
      access, refresh, expiresAt: expiresAt.value
    }))
  }

  /** 检查 Token 是否即将过期(提前 5 分钟刷新) */
  function isTokenExpiring(): boolean {
    return Date.now() > expiresAt.value - 5 * 60 * 1000
  }

  /** 自动刷新 Token */
  async function refreshAccessToken(): Promise<boolean> {
    if (!refreshToken.value) return false
    try {
      const res = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({refresh_token: refreshToken.value})
      })
      if (!res.ok) return false
      const data = await res.json()
      setTokens(data.access_token, data.refresh_token, data.expires_in)
      return true
    } catch {
      return false
    }
  }

  /** 登出(清除本地 + 通知 MaxKey) */
  async function logout() {
    const idToken = localStorage.getItem('id_token')
    accessToken.value = ''
    refreshToken.value = ''
    localStorage.removeItem('auth_tokens')

    // 通知 MaxKey 使全局 Session 失效
    const logoutUrl = `https://sso.company.com/maxkey/sign/logout`
    window.location.href = logoutUrl
  }

  return {accessToken, setTokens, isTokenExpiring, refreshAccessToken, logout}
})

6.3 路由守卫

鉴权逻辑收口到全局前置守卫,白名单和权限校验一处管完,省得每个页面各写一遍。

typescript 复制代码
// src/router/guard.ts
const WHITE_LIST = ['/login', '/auth/callback', '/public']

router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore()

  // 1. 白名单直接放行
  if (WHITE_LIST.some(path => to.path.startsWith(path))) {
    return next()
  }

  // 2. 无 Token → 跳转 SSO
  if (!authStore.accessToken) {
    redirectToSSO()
    return
  }

  // 3. Token 即将过期 → 自动刷新
  if (authStore.isTokenExpiring()) {
    const refreshed = await authStore.refreshAccessToken()
    if (!refreshed) {
      authStore.logout()
      return
    }
  }

  // 4. 权限校验
  if (to.meta.roles && !hasAnyRole(to.meta.roles as string[])) {
    next('/403')
    return
  }

  next()
})

6.4 单点登出

只清本地 Token 是"假登出"------MaxKey 的全局 Session 还活着,用户切到别的系统照样是登录态。必须再通知 MaxKey 注销:

typescript 复制代码
// 退出登录:先清本地,再跳 MaxKey 登出端点
async function handleLogout() {
  // 1. 通知后端清除 Session
  await fetch('/api/auth/logout', {method: 'POST'})

  // 2. 清除前端 Token
  const authStore = useAuthStore()
  authStore.logout()  // 内部会跳转 MaxKey 登出端点

  // MaxKey 登出后,所有子系统的 Session 都失效
}

七、踩坑实录

坑 1:OIDC 回调地址精确匹配导致本地开发无法认证

现象 :本地开发环境(http://localhost:5173)跳转 MaxKey 认证后,回调报 redirect_uri_mismatch 错误。

原因 :MaxKey 应用配置的回调地址是 https://oa.company.com/login/oauth2/code/maxkey,与本地开发地址不匹配。OIDC 协议要求回调 URL 精确匹配(包括协议、域名、端口)。

解决方案

yaml 复制代码
# 方案 1:在 MaxKey 应用配置中添加开发环境回调地址(快速但不安全)
# 生产环境必须删除开发环境的回调地址!

  # 方案 2(推荐):本地通过 nginx 反向代理统一域名
  # nginx.conf
  server {
  listen 443 ssl;
  server_name oa.company.com;  # 与生产域名一致

  location / {
  proxy_pass http://localhost:5173;  # 本地开发服务
  }
}
# 本地绑定 hosts: 127.0.0.1 oa.company.com

坑 2:MaxKey JWKS 公钥轮换导致 Token 验证间歇性失败

现象 :生产环境偶尔出现 Token 验证失败,错误信息 Invalid key ID,几分钟后自动恢复。

原因 :MaxKey 定期轮换 JWT 签名密钥,新密钥的 kid 不在本地缓存中。如果恰好在这时验证 Token,公钥缓存中没有对应的 kid 就会失败。

解决方案:公钥验证失败时触发一次强制 JWKS 刷新重试:

java 复制代码
// JwtTokenValidator 中增加容错逻辑
private RSAPublicKey getPublicKeyWithRetry(String kid) throws Exception {
    // 第一次尝试
    RSAPublicKey key = keyCache.get(kid);
    if (key != null) return key;

    // 缓存未命中 → 强制刷新 JWKS
    refreshKeys();

    key = keyCache.get(kid);
    if (key != null) return key;

    // 仍然没有 → 可能是非法 Token
    throw new SecurityException("Unknown key ID after refresh: " + kid);
}

坑 3:Docker 部署 MySQL 启动顺序问题

现象docker-compose up 后 MaxKey 认证服务启动失败,日志报 Communications link failure

原因 :MaxKey 认证服务依赖 MySQL,但 depends_on 只保证容器启动顺序,不保证 MySQL 已完全就绪接受连接。MySQL 首次启动需要执行初始化 SQL,可能需要 30-60 秒。

解决方案 :使用 Docker Compose 的 healthcheck + condition: service_healthy(见 §3.2 的配置)。

坑 4:SAML / OIDC 协议混用 Session 冲突

现象:部分旧系统使用 SAML 协议接入 MaxKey,新系统使用 OIDC 协议。同一用户在 SAML 系统和 OIDC 系统间跳转时,出现"已登录 MaxKey 但子系统仍要求重新登录"的问题。

原因 :MaxKey 的 SAML 和 OIDC 使用不同的 Session 管理机制。SAML 依赖 NameID,OIDC 依赖 sub claim,两者在 MaxKey 内部的用户标识映射可能不一致。

解决方案

  • 短期:在 MaxKey 中配置"应用账号管理",确保同一用户在不同协议下的标识一致
  • 长期:统一迁移到 OIDC 协议(推荐),SAML 仅保留对遗留系统的兼容

坑 5:Vue3 路由守卫与 OIDC 回调页面死循环

现象 :SSO 登录成功后回调到 /auth/callback?code=xxx&state=xxx,页面不断刷新,陷入死循环。

原因 :路由守卫的白名单使用了精确匹配(to.path === '/auth/callback'),但实际回调 URL 带有 ?code=xxx&state=xxx 查询参数。虽然 to.path 不含查询参数,但 next() 后重新触发导航,导致守卫再次执行。

解决方案 :白名单使用 startsWith 前缀匹配:

typescript 复制代码
// ❌ 精确匹配(可能遗漏)
const WHITE_LIST = ['/login', '/auth/callback']

// ✅ 前缀匹配(推荐)
const WHITE_LIST = ['/login', '/auth/callback', '/public']
if (WHITE_LIST.some(path => to.path.startsWith(path))) {
  return next()
}

八、选型决策树与上线检查清单

8.1 IAM 选型决策树

#mermaid-svg-3pZpiyXIl5sosJgn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3pZpiyXIl5sosJgn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3pZpiyXIl5sosJgn .error-icon{fill:#552222;}#mermaid-svg-3pZpiyXIl5sosJgn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3pZpiyXIl5sosJgn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3pZpiyXIl5sosJgn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3pZpiyXIl5sosJgn .marker.cross{stroke:#333333;}#mermaid-svg-3pZpiyXIl5sosJgn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3pZpiyXIl5sosJgn p{margin:0;}#mermaid-svg-3pZpiyXIl5sosJgn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3pZpiyXIl5sosJgn .cluster-label text{fill:#333;}#mermaid-svg-3pZpiyXIl5sosJgn .cluster-label span{color:#333;}#mermaid-svg-3pZpiyXIl5sosJgn .cluster-label span p{background-color:transparent;}#mermaid-svg-3pZpiyXIl5sosJgn .label text,#mermaid-svg-3pZpiyXIl5sosJgn span{fill:#333;color:#333;}#mermaid-svg-3pZpiyXIl5sosJgn .node rect,#mermaid-svg-3pZpiyXIl5sosJgn .node circle,#mermaid-svg-3pZpiyXIl5sosJgn .node ellipse,#mermaid-svg-3pZpiyXIl5sosJgn .node polygon,#mermaid-svg-3pZpiyXIl5sosJgn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3pZpiyXIl5sosJgn .rough-node .label text,#mermaid-svg-3pZpiyXIl5sosJgn .node .label text,#mermaid-svg-3pZpiyXIl5sosJgn .image-shape .label,#mermaid-svg-3pZpiyXIl5sosJgn .icon-shape .label{text-anchor:middle;}#mermaid-svg-3pZpiyXIl5sosJgn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3pZpiyXIl5sosJgn .rough-node .label,#mermaid-svg-3pZpiyXIl5sosJgn .node .label,#mermaid-svg-3pZpiyXIl5sosJgn .image-shape .label,#mermaid-svg-3pZpiyXIl5sosJgn .icon-shape .label{text-align:center;}#mermaid-svg-3pZpiyXIl5sosJgn .node.clickable{cursor:pointer;}#mermaid-svg-3pZpiyXIl5sosJgn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3pZpiyXIl5sosJgn .arrowheadPath{fill:#333333;}#mermaid-svg-3pZpiyXIl5sosJgn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3pZpiyXIl5sosJgn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3pZpiyXIl5sosJgn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3pZpiyXIl5sosJgn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3pZpiyXIl5sosJgn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3pZpiyXIl5sosJgn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3pZpiyXIl5sosJgn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3pZpiyXIl5sosJgn .cluster text{fill:#333;}#mermaid-svg-3pZpiyXIl5sosJgn .cluster span{color:#333;}#mermaid-svg-3pZpiyXIl5sosJgn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3pZpiyXIl5sosJgn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3pZpiyXIl5sosJgn rect.text{fill:none;stroke-width:0;}#mermaid-svg-3pZpiyXIl5sosJgn .icon-shape,#mermaid-svg-3pZpiyXIl5sosJgn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3pZpiyXIl5sosJgn .icon-shape p,#mermaid-svg-3pZpiyXIl5sosJgn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3pZpiyXIl5sosJgn .icon-shape .label rect,#mermaid-svg-3pZpiyXIl5sosJgn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3pZpiyXIl5sosJgn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3pZpiyXIl5sosJgn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3pZpiyXIl5sosJgn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否











企业认证方案选型
系统数量 ≥ 3?
各系统独立认证

无需 SSO
需要国内身份源

钉钉/企微/飞书?
倾向私有化部署?
需要 SAML 深度集成?
✅ MaxKey

中文原生 + 国内身份源
接受云服务?
✅ Keycloak

协议最全 + 社区最大
需要 Casbin 精细授权?
✅ 阿里云 IDaaS

零运维 + 按需付费
✅ Casdoor

Casbin + ReBAC

8.2 SSO 上线检查清单

markdown 复制代码
# MaxKey SSO 上线检查清单

## 基础设施

□ MaxKey 服务健康检查通过(/maxkey/health)
□ MySQL 数据库连接池配置合理(HikariCP maximumPoolSize)
□ Redis 持久化开启(AOF 模式)
□ Nginx 反向代理配置 HTTPS 证书
□ 服务器时区一致(Asia/Shanghai)

## 安全配置

□ 默认管理员密码已修改
□ TOTP 双因素认证对管理员强制开启
□ Client Secret 不在代码仓库中(使用环境变量注入)
□ HTTPS 强制(HSTS 头)
□ CORS 配置正确(仅允许业务域名)
□ 回调地址精确匹配(无 localhost 残留)

## 业务集成

□ 每个应用在 MaxKey 中正确注册
□ 用户属性映射与业务系统一致
□ 角色映射逻辑验证通过
□ 单点登出链路完整测试
□ 离职用户禁用后立即无法登录

## 性能与监控

□ JWT 公钥缓存刷新策略配置
□ MaxKey 服务接入 Prometheus 监控
□ 登录失败告警配置
□ Token 过期时间合理(Access 2h / Refresh 7d)

九、总结

关键收益

维度 改善 关键指标
效率 一次登录 6 系统通行 日均登录 8 次 → 1 次
安全 统一密码策略 + HTTPS 强制 密码工单降低 85%
运维 离职账号实时禁用 回收周期 1-2 天 → 实时
开发 新系统标准 OIDC 接入 认证开发 3 人天 → 0.5 人天
成本 MaxKey 开源零授权费 年成本 ≈ 6,000 元(vs IDaaS 10 万+)

适用场景

  • 最适合:国内企业多系统统一认证、私有化部署需求、钉钉/企微/飞书身份源对接
  • 需注意:MaxKey 社区版文档相对 Keycloak 较少,遇到问题需依赖社区群或阅读源码
  • 不适合:全球化团队(Keycloak 更合适)、需要 ABAC 精细授权(Casdoor 更合适)

版本时效性

版本提示: 本文基于 MaxKey 4.1.11 GA(2026-02-02 发布)。MaxKey 迭代较快,OIDC 端点路径在不同大版本间可能有变化,接入前请对照官方文档确认:https://www.maxkey.top/doc/docs/am/sso/oidc/
这套方案在我们环境里跑了半年多,目前比较稳。你们在做统一认证时踩过哪些坑,欢迎评论区聊聊。

相关推荐
user_admin_god2 小时前
第 09 篇:实践一 —— 文本摘要(Map-Reduce 分块)
java·人工智能·spring boot·语言模型
vx_BS813302 小时前
【项目编号:project49759】Spring Boot 宠物综合服务平台:商城、预约、咨询、活动与健康档案的一体化实现
java·spring boot·eclipse·tomcat·intellij-idea
user_admin_god2 小时前
第 05 篇:结构化输出 —— 让模型稳定返回 JSON
java·人工智能·spring boot·语言模型
驭渊的小故事2 小时前
Spring Boot 注解详解01
java·前端·spring boot
前端初见3 小时前
Java+AI零基础入门到大牛
java·spring boot·spring·java-ee·intellij-idea
RuoyiOffice3 小时前
SpringBoot3+PaddleOCR 发票识别:不接云端大模型,本地飞桨怎么把专票读进进项单
spring boot·vue3·飞桨·paddleocr·spring boot 3·发票ocr·进项发票
ly76893 小时前
Spring Boot 3.2 虚拟线程在 Web 层与 JDBC 池的实践边界
前端·spring boot·后端·tomcat·jdbc·虚拟线程
QQ_21696290963 小时前
基于SpringBoot+Vue的小生活平台的设计与实现
java·数据库·vue.js·spring boot·spring·微信小程序·生活
不爱运动的跑者3 小时前
OAuth 2.0 认证中心落地指南:Keycloak 26 打通 SSO、角色鉴权与令牌校验
keycloak·oauth2·认证中心