技术栈版本: 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 系统架构

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 端口)创建应用:
- 进入 应用管理 → 应用注册
- 填写应用基本信息:
| 配置项 | 值 | 说明 |
|---|---|---|
| 应用名称 | 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 原生支持钉钉身份源,组织架构同步不用自己写一行代码:
- 在钉钉开放平台创建企业内部应用,获取
AppKey和AppSecret - 在 MaxKey 管理端进入 身份源管理 → 新增 → 钉钉
- 填入 AppKey / AppSecret,配置同步策略
- 启用后,钉钉组织架构自动同步到 MaxKey 用户体系
4.3 登录策略与 MFA
| 策略 | 推荐配置 | 说明 |
|---|---|---|
| 密码策略 | 8 位以上 + 大小写 + 数字 + 特殊符号 | 防止弱密码 |
| 会话超时 | 8 小时(工作日) | 平衡安全与体验 |
| TOTP 双因素 | 管理员账号强制开启 | 防止账号被盗 |
| IP 白名单 | 管理端仅限内网访问 | 减少攻击面 |
五、Spring Boot 3.x 后端集成
5.1 OIDC 授权码模式认证流程
先把 OIDC 授权码模式的完整链路走一遍,后面配 Spring Security 出了问题,才知道卡在哪一步。

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-uri、token-uri、user-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。

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 选型决策树

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 端点路径在不同大版本间可能有变化,接入前请对照官方文档确认:www.maxkey.top/doc/docs/am...
这套方案在我们环境里跑了半年多,目前比较稳。你们在做统一认证时踩过哪些坑,欢迎评论区聊聊。