编辑
2026-10-25
学习记录
00

目录

0. 先说清楚:MaxKey 和标准 OIDC 的两处差异
1. 用 Docker 起一个 MaxKey
2. 在管理台注册 OIDC 应用
2.1 基本信息
2.2 OIDC 页签
3. 端点清单
4. Spring Boot 依赖与配置
4.1 依赖
4.2 application.yml
5. SecurityConfig
6. 用户信息:MaxKey 到底给了什么
6.1 ID Token
6.2 UserInfo
6.3 角色
7. 单点注销
8. 前后端分离与微服务
8.1 BFF(推荐)
8.2 SPA 直连
8.3 服务间调用
9. 十个常见坑
坑 1:用了 issuer-uri
坑 2:scope 没加 openid
坑 3:ID Token 没签名
坑 4:UserInfo 返回 JWT
坑 5:preferred_username 当登录名
坑 6:回调地址不精确
坑 7:容器里的地址和浏览器不一致
坑 8:以为 Token 里有角色
坑 9:注销只清了本地 Session
坑 10:时钟偏移
10. 上线前验收清单
11. 总结
参考资料

SSO 实战系列(3/3):这一篇把 Spring Boot 接 MaxKey 的每个配置项落到具体值上。

和 Keycloak 那篇最大的不同:MaxKey 的发现文档不能直接喂给 Spring 的 issuer-uri,角色也不在 Token 里。这两点如果不提前处理,登录会在很靠后的位置失败。

上篇《Spring Boot 3 接入 Keycloak 实战》讲的是标准 OIDC 的最佳路径。这一篇讲 MaxKey 4.2.0 在同样场景下的实际接法,包括它和标准实现不完全一致的地方。

示例环境:Spring Boot 3.5.x、Spring Security 6.5.x、MaxKey 4.2.0、Java 21、Maven。

TL;DR

  • MaxKey 4.2.0 默认上下文是 /sign,OIDC 端点在 /sign/authz/oauth/v20/...,用显式端点配置,不要用 issuer-uri。
  • 应用配置有三处必须改:scope 加上 openid,profile,email;签名算法选 RS256;UserInfo 返回格式选 NORMAL。
  • sub 默认是用户名,建议改成 userId,避免用户改名后本地角色对不上。
  • MaxKey 的 ID Token 和 UserInfo 都没有角色字段,授权要在业务系统里按 userId/sub 查。
  • 注销跳 /sign/force/logout?redirect_uri=...,不能直接用 Spring 的标准注销 handler。
  • 文中配置和端点来自 MaxKey 4.2.0 源码与官方文档,代码按 Spring Security 6.5 的 DSL 整理,但没有在真实 MaxKey 实例上做端到端联调,上线前请按文末清单逐项验证。

0. 先说清楚:MaxKey 和标准 OIDC 的两处差异

如果你的预期是“配完 client-id 和 secret 就能登录”,MaxKey 会让你多花几个小时。差异集中在两个地方。

第一,发现文档和 Spring 的约定不一致。

Spring Security 用 issuer-uri 时,会请求 <issuer>/.well-known/openid-configuration,并且强制要求发现文档里的 issuer 字段与配置值完全一致,否则启动或登录时报:

text
The Issuer "..." provided in the configuration metadata did not match the requested issuer "..."

MaxKey 的发现文档在 /sign/authz/oauth/v20/.well-known/openid-configuration,不带 client_id 参数时返回的 issuer 是带 /maxkey 的上下文形态,两边对不上。所以不要把 issuer-uri 指向 MaxKey,直接手配四个端点更省事。

第二,Token 里没有角色。

MaxKey 的 ID Token 只有标准声明,UserInfo 只返回用户属性。角色必须由业务系统自己维护——这也符合“认证归 IdP,授权归业务”的分工,但和 Keycloak 的体验差别很大。

下面按部署、注册应用、写代码的顺序来。


1. 用 Docker 起一个 MaxKey

MaxKey 官方仓库自带 Docker Compose,包含 MySQL、认证服务、认证前端、管理服务、管理前端和 Nginx。

bash
git clone --depth 1 https://github.com/dromara/MaxKey.git cd MaxKey/deployment/docker docker compose up -d

启动后的端口和入口:

服务端口入口
认证服务9527上下文 /sign
认证前端(用户门户)8527http://localhost/maxkey/
管理服务9526上下文 /maxkey-mgt-api
管理前端8526http://localhost/maxkey-mgt/
MySQL3306库名 maxkey
Nginx80反向代理上面几个路径

默认管理员账号是 admin / maxkey。第一次登录就改密码,并且不要把这个管理台暴露到公网。

镜像和数据库的注意事项:

  • 官方 Compose 使用 mysql:8.4.2,字符集 utf8mb4;
  • MaxKey 4.2.0 要求 JDK 21+、MySQL 8.4+;
  • 官方 Compose 里没有 Redis 服务,但管理端配置保留了 Redis 项,按你的版本确认是否必填;
  • 生产环境要用固定域名访问,不要让 MaxKey 从请求头推断地址,否则回调地址会乱。

验证服务是否起来:

bash
curl -s "http://localhost:9527/sign/authz/oauth/v20/.well-known/openid-configuration" | head -20

能返回 JSON(哪怕内容和你预期不完全一样)就说明认证服务是通的。


2. 在管理台注册 OIDC 应用

登录管理台 http://localhost/maxkey-mgt/,进入「应用管理 → 应用列表 → 新建应用」。

2.1 基本信息

字段建议值说明
应用名称spring-demo只用于展示
协议OpenID_Connect_v1.0选 OAuth2 不会出现 OIDC 页签
Client IDspring-demo与 Spring 配置一致
Client Secret点「生成」后复制保存只显示一次,丢了就重置
回调地址http://localhost:8081/login/oauth2/code/maxkey必须精确匹配,包括端口和路径
授权类型authorization_code服务端 Session 模式只要这个
Scopeopenid、profile、email默认只有 read,必须手动加
PKCE否BFF / 服务端模式不需要;纯 SPA 再开
同意页自动(auto)内部系统不需要每次弹同意

Scope 是最容易漏的一步。默认值只有 read,不加 openid 就拿不到 ID Token,Spring 的 OIDC 登录会直接失败。

2.2 OIDC 页签

协议选成 OpenID_Connect_v1.0 后,会多出一个 OIDC 配置页签。

字段建议值原因
Issuer留空,或填 http://localhost:9527/sign/maxkey显式端点模式下 Spring 不校验 iss
Audience留空MaxKey 默认把 clientId 写进 aud
SubjectuserId决定 sub 取什么;默认 username,用户改名后 sub 会变
签名算法RS256默认是空,ID Token 不带签名,Spring 会校验失败
签名密钥选完算法后自动生成不要手动改;丢了客户端全部失效
UserInfo 返回格式NORMAL默认返回 JWT,Spring 按 JSON 解析,会报错
Token 有效期300 秒(默认)内部系统可以适当延长
Refresh 有效期300 秒(默认)需要续期时再调

保存后建议再确认一次发现文档:

bash
curl -s "http://localhost:9527/sign/authz/oauth/v20/.well-known/openid-configuration?client_id=spring-demo" | python -m json.tool

重点看 authorization_endpoint、token_endpoint、userinfo_endpoint、jwks_uri 是否和你下面要配的地址一致。


3. 端点清单

MaxKey 4.2.0 的默认上下文是 /sign,端点如下(MAXKEY_BASE 指 http://localhost:9527/sign):

用途地址
发现文档{MAXKEY_BASE}/authz/oauth/v20/.well-known/openid-configuration
授权{MAXKEY_BASE}/authz/oauth/v20/authorize
Token{MAXKEY_BASE}/authz/oauth/v20/token
UserInfo{MAXKEY_BASE}/api/connect/v10/userinfo
JWKS{MAXKEY_BASE}/authz/oauth/v20/jwks
注销{MAXKEY_BASE}/force/logout

如果前面有官方那套 Nginx,/sign/ 会被代理到 9527,这时 MAXKEY_BASE 可以写成 http://localhost/sign。最终以你在浏览器里能打开的地址为准,不要混用容器内地址和宿主机地址。


4. Spring Boot 依赖与配置

4.1 依赖

xml
<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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 需要解析 ID Token / 资源服务器时再加 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>

Gradle:

groovy
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web'

4.2 application.yml

yaml
server: port: 8081 maxkey: base-url: http://localhost:9527/sign logout-redirect: http://localhost:8081 spring: security: oauth2: client: registration: maxkey: client-id: spring-demo client-secret: ${MAXKEY_CLIENT_SECRET} authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: openid,profile,email client-name: MaxKey provider: maxkey: authorization-uri: ${maxkey.base-url}/authz/oauth/v20/authorize token-uri: ${maxkey.base-url}/authz/oauth/v20/token user-info-uri: ${maxkey.base-url}/api/connect/v10/userinfo jwk-set-uri: ${maxkey.base-url}/authz/oauth/v20/jwks user-name-attribute: sub

几个点:

  • 不用 issuer-uri,原因见第 0 节;
  • user-name-attribute 用 sub,它由 MaxKey 的 Subject 配置决定;
  • client-secret 从环境变量注入,不要提交到仓库;
  • redirect-uri 必须和 MaxKey 里填的回调地址完全一致。

5. SecurityConfig

java
import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Set; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Configuration @EnableMethodSecurity public class SecurityConfig { @Value("${maxkey.base-url}") private String maxkeyBaseUrl; @Value("${maxkey.logout-redirect}") private String logoutRedirect; @Bean SecurityFilterChain securityFilterChain( HttpSecurity http, UserDetailsService roleService) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/public/**", "/error").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userAuthoritiesMapper(authoritiesMapper(roleService)))) .logout(logout -> logout .logoutSuccessHandler(maxkeyLogoutHandler())); return http.build(); } private GrantedAuthoritiesMapper authoritiesMapper(UserDetailsService roleService) { return authorities -> { Set<GrantedAuthority> mapped = new HashSet<>(authorities); mapped.add(new SimpleGrantedAuthority("ROLE_USER")); authorities.stream() .filter(OAuth2UserAuthority.class::isInstance) .map(OAuth2UserAuthority.class::cast) .findFirst() .ifPresent(authority -> { Object subject = authority.getAttributes().get("sub"); if (subject == null) { return; } roleService.loadUserByUsername(String.valueOf(subject)) .getAuthorities() .forEach(mapped::add); }); return mapped; }; } private LogoutSuccessHandler maxkeyLogoutHandler() { String target = maxkeyBaseUrl + "/force/logout?redirect_uri=" + URLEncoder.encode(logoutRedirect, StandardCharsets.UTF_8); return (request, response, authentication) -> response.sendRedirect(target); } }

说明:

  • UserDetailsService 在这里被当作“按用户名查本地角色”的入口,实现里只需要根据 sub 返回角色,不用校验密码;
  • 首次登录的用户还不在业务库里时,RoleService 要返回空角色列表,不要抛 UsernameNotFoundException,否则登录会被打断;
  • 如果 sub 用的是 userId,查询键也要改成 userId,别再按用户名查;
  • ROLE_USER 是兜底,保证登录用户至少有基础权限;
  • logoutSuccessHandler 先清本地 Session(Spring 的 LogoutFilter 已经做了),再跳到 MaxKey 的注销入口。

配套的角色查询可以很简单:

java
import java.util.List; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; @Service public class RoleService implements UserDetailsService { private final UserRoleMapper userRoleMapper; public RoleService(UserRoleMapper userRoleMapper) { this.userRoleMapper = userRoleMapper; } @Override public UserDetails loadUserByUsername(String subject) { List<GrantedAuthority> authorities = userRoleMapper.findRoles(subject).stream() .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) .toList(); return User.withUsername(subject) .password("") .authorities(authorities) .build(); } }

这里不校验密码,只借 UserDetailsService 的接口查角色;密码认证已经由 MaxKey 完成了。


6. 用户信息:MaxKey 到底给了什么

这是接入第二个容易踩空的地方:ID Token 和 UserInfo 的内容不一样。

6.1 ID Token

只有标准声明:

Claim内容
sub按 Subject 配置,默认用户名
issIssuer 配置值,可为空
audclientId
iat / exp签发、过期时间
jti随机 ID
nonce回传 Spring 的 nonce,缺失会导致 OIDC 校验失败
at_hash隐式流才需要,授权码模式通常没有

没有姓名、邮箱、部门,更没有角色。

6.2 UserInfo

按 scope 返回:

Scope主要字段
基础sub、institution、在线会话标识
profileuserId、user、name、preferred_username、given_name、family_name、nickname、displayName、departmentId、department、gender、locale、birthdate 等
emailemail、email_verified
phonephone_number、phone_number_verified
address地址对象

两个字段特别容易搞混:

  • user 是登录名;
  • preferred_username 是显示名(displayName),不是登录名。

如果业务代码拿 preferred_username 当登录名用,遇到同名用户会出问题。要登录名就用 user,要稳定主键就用 sub / userId。

6.3 角色

MaxKey 的 OIDC 实现不返回角色。可选的做法:

  1. 业务库维护角色(推荐):登录后按 sub 查本地角色表,上面的 RoleService 就是这种做法;
  2. 用部门做粗粒度映射:把 department 映射成 ROLE_DEPT_xxx,适合组织结构稳定的场景;
  3. 同步任务:定时从 MaxKey 或 HR 系统同步用户组到业务库,登录时只查本地;
  4. 不要把业务权限配进 MaxKey 再指望它出现在 Token 里,当前版本没有这个能力。

7. 单点注销

MaxKey 的注销入口:

text
{MAXKEY_BASE}/force/logout?redirect_uri={业务系统地址}

它的行为是:清理 MaxKey 的本地会话,跳转到 MaxKey 前端的注销页,再带上 redirect_uri 回到业务系统。

Spring 侧不要用 OidcClientInitiatedLogoutSuccessHandler,因为显式端点模式下没有发现文档,取不到 end_session_endpoint。用第 5 节里的自定义 handler 即可。

再强调一次:这只保证当前应用和 MaxKey 的会话退出。其他接入系统的本地 Session 不会自动失效。要做即时全局退出,需要:

  • 缩短业务系统的 Session 超时;
  • 关键操作重新鉴权;
  • 或者上 backchannel logout(MaxKey 的支持情况按你的版本确认)。

8. 前后端分离与微服务

8.1 BFF(推荐)

浏览器只拿 Cookie,Spring 负责登录和转发,MaxKey 只认 Spring 这个客户端。配置和上面的 Session 模式完全一样,前端通过同源代理访问后端即可。

8.2 SPA 直连

  • MaxKey 应用里把 PKCE 打开;
  • Spring 侧改为资源服务器,校验 Access Token;
  • Access Token 只放内存,Refresh Token 不要放 localStorage;
  • 注意 MaxKey 的 Access Token 默认只有 300 秒,前端要处理静默续期。

8.3 服务间调用

和 Keycloak 那篇一致:用 client_credentials 拿服务令牌,或者在网关统一换发。MaxKey 的应用配置里同样可以建一个只有 client_credentials 授权的客户端。

区别在于:MaxKey 的 Token 里没有角色,服务间鉴权要靠 scope 或业务侧校验,不要指望 roles 声明。


9. 十个常见坑

坑 1:用了 issuer-uri

Spring 要求发现文档的 issuer 与配置值完全一致,MaxKey 的返回值对不上。改显式端点。

坑 2:scope 没加 openid

默认只有 read,没有 openid 就没有 ID Token,登录流程直接失败。

坑 3:ID Token 没签名

签名算法留空时,Token 是不带签名的 JWT,Spring 的 NimbusJwtDecoder 无法验证,报算法不支持。选 RS256。

坑 4:UserInfo 返回 JWT

默认返回 application/jwt,Spring 按 JSON 解析用户信息,报内容类型或解析错误。把返回格式改成 NORMAL。

坑 5:preferred_username 当登录名

它是显示名。登录名用 user,稳定主键用 userId。

坑 6:回调地址不精确

http://localhost:8081 和 http://localhost:8081/ 不是一回事,localhost 和 127.0.0.1 也不是。MaxKey 里填什么,Spring 就必须是什么。

坑 7:容器里的地址和浏览器不一致

Spring 配 localhost:9527,但应用跑在容器里,访问到的是容器自己。用双方都能解析的域名。

坑 8:以为 Token 里有角色

MaxKey 的 OIDC 不返回角色,hasRole('admin') 不会凭空生效。

坑 9:注销只清了本地 Session

不跳 /force/logout,MaxKey 的 SSO 会话还在,用户再点登录会直接免登录进来。

坑 10:时钟偏移

iat / exp 对时间敏感,服务器不同步会出现莫名其妙的 401。所有节点接 NTP。


10. 上线前验收清单

  • 未登录访问受保护页面 → 跳转 MaxKey 登录 → 登录后回到原页面
  • 第二个接入系统无需再次输入密码
  • ID Token 的 alg 是 RS256,aud 等于 clientId
  • UserInfo 返回 JSON,sub / user / email 与预期一致
  • 本地角色按 sub(或 userId)正确映射,admin 能进管理接口,普通用户 403
  • 注销后 MaxKey 会话和本地 Session 都失效,再访问需要重新登录
  • API 未认证返回 401 JSON,而不是 302 到登录页
  • 多实例部署时 Session 已共享(Spring Session + Redis)或使用无状态 Token
  • 日志记录 sub,不打印完整 Token 和 client secret
  • HTTPS、域名、反向代理头配置正确
  • Client Secret 和签名密钥可轮换,且有备份与回滚方案
  • 管理台不使用默认密码,限制访问来源

11. 总结

接 MaxKey 的路径可以压成四步:

  1. 部署:官方 Docker Compose 起来,确认 /sign 下的端点可访问;
  2. 注册应用:协议选 OIDC,scope 加 openid,profile,email,签名 RS256,UserInfo NORMAL,Subject 用 userId;
  3. Spring 配置:显式配四个端点,不要用 issuer-uri;
  4. 授权与注销:角色按 sub 查业务库,注销跳 /sign/force/logout。

这套配置没有 Keycloak 那么“开箱即用”,但把默认值改对之后,运行起来是稳定的。真正需要提前想清楚的是角色在哪维护——这一步想不明白,后面会反复返工。


参考资料

本文作者:Weee

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!