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
/sign,OIDC 端点在 /sign/authz/oauth/v20/...,用显式端点配置,不要用 issuer-uri。openid,profile,email;签名算法选 RS256;UserInfo 返回格式选 NORMAL。sub 默认是用户名,建议改成 userId,避免用户改名后本地角色对不上。userId/sub 查。/sign/force/logout?redirect_uri=...,不能直接用 Spring 的标准注销 handler。如果你的预期是“配完 client-id 和 secret 就能登录”,MaxKey 会让你多花几个小时。差异集中在两个地方。
第一,发现文档和 Spring 的约定不一致。
Spring Security 用 issuer-uri 时,会请求 <issuer>/.well-known/openid-configuration,并且强制要求发现文档里的 issuer 字段与配置值完全一致,否则启动或登录时报:
textThe 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 的体验差别很大。
下面按部署、注册应用、写代码的顺序来。
MaxKey 官方仓库自带 Docker Compose,包含 MySQL、认证服务、认证前端、管理服务、管理前端和 Nginx。
bashgit clone --depth 1 https://github.com/dromara/MaxKey.git
cd MaxKey/deployment/docker
docker compose up -d
启动后的端口和入口:
| 服务 | 端口 | 入口 |
|---|---|---|
| 认证服务 | 9527 | 上下文 /sign |
| 认证前端(用户门户) | 8527 | http://localhost/maxkey/ |
| 管理服务 | 9526 | 上下文 /maxkey-mgt-api |
| 管理前端 | 8526 | http://localhost/maxkey-mgt/ |
| MySQL | 3306 | 库名 maxkey |
| Nginx | 80 | 反向代理上面几个路径 |
默认管理员账号是 admin / maxkey。第一次登录就改密码,并且不要把这个管理台暴露到公网。
镜像和数据库的注意事项:
mysql:8.4.2,字符集 utf8mb4;验证服务是否起来:
bashcurl -s "http://localhost:9527/sign/authz/oauth/v20/.well-known/openid-configuration" | head -20
能返回 JSON(哪怕内容和你预期不完全一样)就说明认证服务是通的。
登录管理台 http://localhost/maxkey-mgt/,进入「应用管理 → 应用列表 → 新建应用」。
| 字段 | 建议值 | 说明 |
|---|---|---|
| 应用名称 | spring-demo | 只用于展示 |
| 协议 | OpenID_Connect_v1.0 | 选 OAuth2 不会出现 OIDC 页签 |
| Client ID | spring-demo | 与 Spring 配置一致 |
| Client Secret | 点「生成」后复制保存 | 只显示一次,丢了就重置 |
| 回调地址 | http://localhost:8081/login/oauth2/code/maxkey | 必须精确匹配,包括端口和路径 |
| 授权类型 | authorization_code | 服务端 Session 模式只要这个 |
| Scope | openid、profile、email | 默认只有 read,必须手动加 |
| PKCE | 否 | BFF / 服务端模式不需要;纯 SPA 再开 |
| 同意页 | 自动(auto) | 内部系统不需要每次弹同意 |
Scope 是最容易漏的一步。默认值只有 read,不加 openid 就拿不到 ID Token,Spring 的 OIDC 登录会直接失败。
协议选成 OpenID_Connect_v1.0 后,会多出一个 OIDC 配置页签。
| 字段 | 建议值 | 原因 |
|---|---|---|
| Issuer | 留空,或填 http://localhost:9527/sign/maxkey | 显式端点模式下 Spring 不校验 iss |
| Audience | 留空 | MaxKey 默认把 clientId 写进 aud |
| Subject | userId | 决定 sub 取什么;默认 username,用户改名后 sub 会变 |
| 签名算法 | RS256 | 默认是空,ID Token 不带签名,Spring 会校验失败 |
| 签名密钥 | 选完算法后自动生成 | 不要手动改;丢了客户端全部失效 |
| UserInfo 返回格式 | NORMAL | 默认返回 JWT,Spring 按 JSON 解析,会报错 |
| Token 有效期 | 300 秒(默认) | 内部系统可以适当延长 |
| Refresh 有效期 | 300 秒(默认) | 需要续期时再调 |
保存后建议再确认一次发现文档:
bashcurl -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 是否和你下面要配的地址一致。
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。最终以你在浏览器里能打开的地址为准,不要混用容器内地址和宿主机地址。
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:
groovyimplementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web'
yamlserver:
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 里填的回调地址完全一致。javaimport 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 的注销入口。配套的角色查询可以很简单:
javaimport 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 完成了。
这是接入第二个容易踩空的地方:ID Token 和 UserInfo 的内容不一样。
只有标准声明:
| Claim | 内容 |
|---|---|
sub | 按 Subject 配置,默认用户名 |
iss | Issuer 配置值,可为空 |
aud | clientId |
iat / exp | 签发、过期时间 |
jti | 随机 ID |
nonce | 回传 Spring 的 nonce,缺失会导致 OIDC 校验失败 |
at_hash | 隐式流才需要,授权码模式通常没有 |
没有姓名、邮箱、部门,更没有角色。
按 scope 返回:
| Scope | 主要字段 |
|---|---|
| 基础 | sub、institution、在线会话标识 |
profile | userId、user、name、preferred_username、given_name、family_name、nickname、displayName、departmentId、department、gender、locale、birthdate 等 |
email | email、email_verified |
phone | phone_number、phone_number_verified |
address | 地址对象 |
两个字段特别容易搞混:
user 是登录名;preferred_username 是显示名(displayName),不是登录名。如果业务代码拿 preferred_username 当登录名用,遇到同名用户会出问题。要登录名就用 user,要稳定主键就用 sub / userId。
MaxKey 的 OIDC 实现不返回角色。可选的做法:
sub 查本地角色表,上面的 RoleService 就是这种做法;department 映射成 ROLE_DEPT_xxx,适合组织结构稳定的场景;MaxKey 的注销入口:
text{MAXKEY_BASE}/force/logout?redirect_uri={业务系统地址}
它的行为是:清理 MaxKey 的本地会话,跳转到 MaxKey 前端的注销页,再带上 redirect_uri 回到业务系统。
Spring 侧不要用 OidcClientInitiatedLogoutSuccessHandler,因为显式端点模式下没有发现文档,取不到 end_session_endpoint。用第 5 节里的自定义 handler 即可。
再强调一次:这只保证当前应用和 MaxKey 的会话退出。其他接入系统的本地 Session 不会自动失效。要做即时全局退出,需要:
浏览器只拿 Cookie,Spring 负责登录和转发,MaxKey 只认 Spring 这个客户端。配置和上面的 Session 模式完全一样,前端通过同源代理访问后端即可。
和 Keycloak 那篇一致:用 client_credentials 拿服务令牌,或者在网关统一换发。MaxKey 的应用配置里同样可以建一个只有 client_credentials 授权的客户端。
区别在于:MaxKey 的 Token 里没有角色,服务间鉴权要靠 scope 或业务侧校验,不要指望 roles 声明。
issuer-uriSpring 要求发现文档的 issuer 与配置值完全一致,MaxKey 的返回值对不上。改显式端点。
openid默认只有 read,没有 openid 就没有 ID Token,登录流程直接失败。
签名算法留空时,Token 是不带签名的 JWT,Spring 的 NimbusJwtDecoder 无法验证,报算法不支持。选 RS256。
默认返回 application/jwt,Spring 按 JSON 解析用户信息,报内容类型或解析错误。把返回格式改成 NORMAL。
preferred_username 当登录名它是显示名。登录名用 user,稳定主键用 userId。
http://localhost:8081 和 http://localhost:8081/ 不是一回事,localhost 和 127.0.0.1 也不是。MaxKey 里填什么,Spring 就必须是什么。
Spring 配 localhost:9527,但应用跑在容器里,访问到的是容器自己。用双方都能解析的域名。
MaxKey 的 OIDC 不返回角色,hasRole('admin') 不会凭空生效。
不跳 /force/logout,MaxKey 的 SSO 会话还在,用户再点登录会直接免登录进来。
iat / exp 对时间敏感,服务器不同步会出现莫名其妙的 401。所有节点接 NTP。
alg 是 RS256,aud 等于 clientIdsub / user / email 与预期一致sub(或 userId)正确映射,admin 能进管理接口,普通用户 403sub,不打印完整 Token 和 client secret接 MaxKey 的路径可以压成四步:
/sign 下的端点可访问;openid,profile,email,签名 RS256,UserInfo NORMAL,Subject 用 userId;issuer-uri;sub 查业务库,注销跳 /sign/force/logout。这套配置没有 Keycloak 那么“开箱即用”,但把默认值改对之后,运行起来是稳定的。真正需要提前想清楚的是角色在哪维护——这一步想不明白,后面会反复返工。
本文作者:Weee
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!