Security 企业认证:LDAP、SAML、证书与多因素
企业认证首先要回答:谁检查凭据,谁把外部身份映射为本地账号,谁决定权限,谁负责撤销。目录 bind 成功、IdP 签名有效、TLS 握手成功,分别证明不同的事实;任何一项都不能自动证明这个人有权修改当前租户的订单。
本页承担 PRD S06 / Issue 68 的企业扩展。源码固定 Spring Security 7.0.8,commit 0102bb2e879e00632f30864785f0f46127877a4d,配置采用课程已锁定的 JDK 21、Boot 4.0.9、Framework 7.0.10 组合。本文区分三种证据:原码摘录证明这个版本怎样实现;配置示例说明必要依赖、Bean 和外部前提;失败时序与容量数字是标明输入的预期推演。没有连接企业 LDAP、IdP、CA、CAS 或 KDC,没有把配置核查写成这些服务已经实跑。
密码编码、表单登录、会话保存和撤销的权威讲解见认证与会话;浏览器携带凭据的防护见CSRF、CORS 与 Headers;OAuth/OIDC 的角色、令牌与授权服务器见OAuth2 与 OIDC,对象及租户规则见资源授权。本文只解释企业机制带来的不同边界。响应式 HTTP、Reactive Context 和 RSocket 实验由 U31 响应式单元承接,不用其运行结果替代本页的企业验证。
版本、模块与合成企业前提
以合成企业 EXAMPLE.TEST 为例:员工 alice 属于 finance-read 组,应用对账单读取要求 ROLE_FINANCE_READ,资金调整另要求近期第二因子。目录管理员维护账号和组,IdP 管理员签发断言,应用管理员把外部身份关联到稳定内部用户 ID,安全入口管理员维护 TLS/代理信任。邮件地址、可改名的 CN、不同 IdP 中同名的 alice 都不能作为跨组织天然唯一标识。映射表至少包括 (identity-source, external-subject, internal-user-id, status, mapping-version);是否允许重命名、合并、重复 subject 必须有确定规则。
| 能力与 Maven artifact | 本页的权威范围 | 7.0.8 的边界 |
|---|---|---|
spring-security-ldap | DN/search bind、密码比较、组映射、AD、用户属性 | Servlet 用户名密码认证;Spring LDAP/JNDI 是阻塞调用;7 移除嵌入 ApacheDS,保留 UnboundID 路线 |
spring-security-saml2-service-provider | RP/SP 登录、请求关联、验签/解密、metadata、SLO、多 IdP | 服务提供方,不能当作 IdP 实现;7 移除 OpenSAML4,下面使用 OpenSAML5 |
spring-security-web / config 的 X.509、pre-auth | 容器证书、header/request attribute、容器 principal 与权限映射 | TLS/外部系统先认证,过滤器提取身份;不替客户端证书做完整握手验证 |
spring-security-core / web / config 的 OTT、MFA、JAAS | 一次性令牌、授权因子、新鲜度、LoginModule 桥接 | OTT 从 6.4 已有;MFA 注解/因子模型是 7.0;本版本注解明确不支持 Reactive MFA 编排 |
spring-security-webauthn | passkey 注册、断言验证、仓库、删除与恢复 | 6.4 已有基础支持,6.5 增加 JDBC 等;默认 UV 为 preferred,不能推导为强制生物识别 |
spring-security-cas | ST、service 绑定、SLO、PT/PGT 与缓存 | 当前 Provider 追加 FACTOR_CAS,不是旧文档所写的 FACTOR_BEARER |
spring-security-kerberos-core/web/client/test | 用户密码、SPNEGO、keytab、客户端、Kerberized LDAP;test 为测试工具 | 7 已并入主项目,groupId 是 org.springframework.security;不是照搬独立扩展的旧版本号 |
spring-security-access | 仅交代迁移边界,Run-As 正文回指 U30 | 7 把旧 Access API 移到此模块;现代 @EnableMethodSecurity 不会自动启用旧 Run-As |
以上按固定导航、7.0 变更和具体类核对。用于版本对比的 6.4.0 固定提交为 13816b7978612cf5d6fd0fdbe7a9fe4e3e690af2,6.5.0 为 0fd0e9335ae3a6ff3538045d098dd6b94679d99e;6.4 变更、6.5 变更只支持这里明确写出的差异,不代表对所有 6.x 补丁做了普查。
下面给出配置片段的共同 Maven 外壳。每次只选一种 *Config 作为应用配置,避免多个全匹配链争抢请求或多个 AuthenticationManager Bean 冲突;EnterpriseApplication 与所选配置位于 enterprise 包。依赖表中的扩展按所选方案添加到 <dependencies>,普通依赖版本由 Boot 4.0.9 BOM 管理,Security 应解析为 7.0.8;不要给其中一个 Security 模块单独降级。SAML 才需要额外 Shibboleth 仓库。
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.9</version><relativePath/>
</parent>
<groupId>study</groupId><artifactId>enterprise-auth</artifactId><version>1.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>package enterprise;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class EnterpriseApplication {
public static void main(String[] args) {
SpringApplication.run(EnterpriseApplication.class, args);
}
}启动前把本节指定的配置、证书和依赖放入独立教学工程,再执行 mvn dependency:tree、mvn package、java -jar target/enterprise-auth-1.0.jar。这是一条待外部前提具备后执行的操作路径,不是本文报告的企业运行结果。下文的 example.test 地址、realm、账号均为合成占位,证书/私钥文件须来自相应隔离测试方;没有这些材料时不应期待启动和登录成功。
LDAP:检索身份、用户 bind 与组权限是三步
DN 是目录中的条目地址,base DN 是 ContextSource 的基点。设 base 为 dc=example,dc=test,用户检索基点写 ou=people,最终在 ou=people,dc=example,dc=test 内查找。把完整 DN 再填进相对 search base 会重复拼接。{0} 的含义还随位置变化:用户过滤器中是用户名,组过滤器中是用户完整 DN,{1} 可用于组过滤器中的用户名。不要手工拼接 LDAP filter,也不要把 DN 转义和 filter 转义混为同一种规则。
bind 模式先用受限检索账号找到唯一用户 DN,再用用户提交的密码向目录 bind;目录返回成功/失败,应用不读取密码散列。LdapAuthenticator 负责认证和必要属性,LdapAuthoritiesPopulator 负责权限,UserDetailsContextMapper 负责把属性映射成本地 UserDetails。这不是把 DAO 的 UserDetailsService 改个名字:UserDetailsService 可以读取目录属性/角色,但不能据此声称完成了 LDAP bind。固定 LDAP 文档。
本配置在共同外壳上添加 org.springframework.boot:spring-boot-starter-data-ldap、org.springframework.security:spring-security-ldap。目录前提是 LDAPS 已配置有效服务端证书且 JVM 信任该测试 CA;应用运行环境提供下列两个属性,秘密从环境注入,不写入教材或版本库:
enterprise.ldap.url=ldaps://directory.example.test:636/dc=example,dc=test
enterprise.ldap.manager-dn=cn=app-reader,ou=service,dc=example,dc=test
enterprise.ldap.manager-password=${LDAP_MANAGER_PASSWORD}package enterprise;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.ldap.LdapBindAuthenticationManagerFactory;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class LdapConfig {
@Bean
DefaultSpringSecurityContextSource directory(
@Value("${enterprise.ldap.url}") String url,
@Value("${enterprise.ldap.manager-dn}") String manager,
@Value("${enterprise.ldap.manager-password}") String password) {
var source = new DefaultSpringSecurityContextSource(url);
source.setUserDn(manager);
source.setPassword(password);
source.setPooled(false);
source.setBaseEnvironmentProperties(Map.of(
"com.sun.jndi.ldap.connect.timeout", "2000",
"com.sun.jndi.ldap.read.timeout", "3000"));
return source;
}
@Bean
AuthenticationManager ldapManager(DefaultSpringSecurityContextSource directory) {
var groups = new DefaultLdapAuthoritiesPopulator(directory, "ou=groups");
groups.setGroupSearchFilter("(member={0})");
groups.setGroupRoleAttribute("cn");
groups.setRolePrefix("ROLE_");
groups.setConvertToUpperCase(true);
groups.setSearchSubtree(true);
var factory = new LdapBindAuthenticationManagerFactory(directory);
factory.setUserSearchBase("ou=people");
factory.setUserSearchFilter("(uid={0})");
factory.setLdapAuthoritiesPopulator(groups);
return factory.createAuthenticationManager();
}
@Bean
SecurityFilterChain ldapChain(HttpSecurity http, AuthenticationManager ldapManager) throws Exception {
return http.authenticationManager(ldapManager)
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.formLogin(Customizer.withDefaults()).build();
}
}连接/读取超时单位均为 JNDI 属性规定的毫秒字符串,是本例的显式预算,不能称为 Security 默认超时。setPooled(false) 明确本例不启连接池;连接池的认证上下文隔离、连接回收和 LDAPS 行为另需验证。组过滤器默认 (member={0})、角色字段默认 cn、默认前缀 ROLE_、默认转大写;默认 SearchControls 是单层,本例显式改为 subtree。所以 cn=finance_read 映射成 ROLE_FINANCE_READ,而合成业务名 finance-read 不能未经映射就假定变成下划线。本场景目录实际使用 cn=finance_read;名称规范化应在受控映射中做,不能让任意组文本变成本地管理员角色。角色实现。
7.0.8 的 BindAuthenticator.authenticate 连续原码如下。注意空密码先拒绝,配置多个 DN pattern 时逐个尝试,未命中才走 search;最后仍没有用户就抛 BadCredentialsException。
固定原码:BindAuthenticator.java,连续 62–99 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
public DirContextOperations authenticate(Authentication authentication) {
DirContextOperations user = null;
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
"Can only process UsernamePasswordAuthenticationToken objects");
String username = authentication.getName();
String password = (String) authentication.getCredentials();
if (!StringUtils.hasLength(password)) {
logger.debug(LogMessage.format("Failed to authenticate since no credentials provided"));
throw new BadCredentialsException(
this.messages.getMessage("BindAuthenticator.emptyPassword", "Empty Password"));
}
// If DN patterns are configured, try authenticating with them directly
for (String dn : getUserDns(username)) {
user = bindWithDn(dn, username, password);
if (user != null) {
break;
}
}
if (user == null) {
logger.debug(LogMessage.of(() -> "Failed to bind with any user DNs " + getUserDns(username)));
}
// Otherwise use the configured search object to find the user and authenticate
// with the returned DN.
if (user == null && getUserSearch() != null) {
logger.trace("Searching for user using " + getUserSearch());
DirContextOperations userFromSearch = getUserSearch().searchForUser(username);
user = bindWithDn(userFromSearch.getDn().toString(), username, password, userFromSearch.getAttributes());
if (user == null) {
logger.debug("Failed to find user using " + getUserSearch());
}
}
if (user == null) {
throw new BadCredentialsException(
this.messages.getMessage("BindAuthenticator.badCredentials", "Bad credentials"));
}
return user;
}输入 alice / 正确密码的预期流程是“检索唯一条目 → 以该 DN bind → 组查询 → 可信 Authentication”,失败不是统一“密码错误”:空密码在本地被拒;错密码使 bind 失败;检索服务账号无 ACL、重复 uid、网络超时分别是权限、数据和基础设施问题。实际 bind 的 getContext(fullDn, password) 与 finally closeContext 见固定原码。删除关闭分支会使突发认证占用目录连接;把网络异常兜底为成功则直接破坏认证不变量。7.0 的 LDAP Provider 在成功结果追加 FACTOR_PASSWORD,不是只要搜索命中就赋予密码因子,见成功构造。
DN 模式、密码比较与属性定制
目录结构稳定且无需检索时,可将上面工厂的两条 search 设置替换为 factory.setUserDnPatterns("uid={0},ou=people"),把“服务账号搜索 + 用户 bind”改为“按模式计算 DN + 用户 bind”。这省了一次查找,却无法覆盖分散在不同 OU 的用户。服务账号仍可能用于组查询;减少搜索不等于完全消除服务身份。
密码比较模式需要单独选择,下面是替换 ldapManager 的完整工厂配置类;沿用 LdapConfig.directory 和链,删除原 ldapManager 方法,导入此类。前提是隔离目录允许检索身份读取 bcrypt 密码属性 pwd,并使用与存储匹配的编码器。它扩大了应用读散列的权限,不是 bind 的安全等价替代。
package enterprise;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.ldap.LdapPasswordComparisonAuthenticationManagerFactory;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
@Configuration(proxyBeanMethods = false)
public class LdapCompareAlternative {
@Bean
AuthenticationManager ldapManager(DefaultSpringSecurityContextSource directory) {
var factory = new LdapPasswordComparisonAuthenticationManagerFactory(
directory, new BCryptPasswordEncoder());
factory.setUserDnPatterns("uid={0},ou=people");
factory.setPasswordAttribute("pwd");
return factory.createAuthenticationManager();
}
}此替代只展示密码比较;需要对账单角色时,应复用前面的显式 DefaultLdapAuthoritiesPopulator 设置并交给工厂,否则不能把“认证成功”写成“具备 FINANCE_READ”。PasswordComparisonAuthenticator 有本地读取属性后 matches 与 LDAP compare 两条路径;设置 PasswordEncoder 会启用本地属性比较。带随机 salt 的 bcrypt 不可能靠再次 encode 得到相同字节来完成服务端等值 compare;没有读属性权限时要选择 bind 或专门设计的目录操作,不能退回明文。两条路径与 setter 原码。
factory.setUserDetailsContextMapper(...) 是属性定制点;mapper 的 mapUserFromContext(ctx, username, authorities) 必须保留已经验证的用户名、状态与权限契约,不能看到 department=finance 就绕过组授权。自定义 mapper 读取的属性受目录 ACL 约束,缺少必要 employeeNumber 应按业务拒绝或标记待关联,不可自动与另一 IdP 的同名账号合并。Mapper 接口。
本地需要嵌入目录时,7.0.8 文档给出 EmbeddedLdapServerContextSourceFactoryBean.fromEmbeddedLdapServer()、setPort(0) 与 classpath users.ldif,并要求 com.unboundid:unboundid-ldapsdk 运行时依赖。port 0 代表选择可用临时端口;LDIF 必须含用户 OU、用户、组 OU 与 groupOfNames/member,随后让同一工厂的 ContextSource 进入上述认证链。此页不创建嵌入实验;它只适合作为隔离替身,不能证明 AD 的 schema、ACL 或企业 TLS。7 已移除 ApacheDS,不应复制旧 ApacheDsContainer。固定嵌入目录章节。
Active Directory 不是普通 DN 模式的别名
AD 通常用 user@domain bind;专用 Provider 处理域和 AD 子错误码,并从目录属性取权限。下面作为 LDAP 的另一个替代,使用相同两个依赖;LDAPS 信任前提不变。域、URL 与 root DN 显式给定,alice 与 UPN 的归一化须统一,不能让第二因子中的 alice@example.test 错配另一内部身份。
package enterprise;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class ActiveDirectoryConfig {
@Bean
ActiveDirectoryLdapAuthenticationProvider adProvider() {
var provider = new ActiveDirectoryLdapAuthenticationProvider(
"example.test", "ldaps://ad.example.test:636", "dc=example,dc=test");
provider.setConvertSubErrorCodesToExceptions(true);
provider.setContextEnvironmentProperties(Map.of(
"com.sun.jndi.ldap.connect.timeout", "2000",
"com.sun.jndi.ldap.read.timeout", "3000"));
return provider;
}
@Bean
SecurityFilterChain adChain(HttpSecurity http, ActiveDirectoryLdapAuthenticationProvider provider) throws Exception {
return http.authenticationProvider(provider)
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.formLogin(Customizer.withDefaults()).build();
}
}convertSubErrorCodesToExceptions 默认 false,本例 true 后,目录返回的密码过期、禁用、账号过期和锁定可分别转成 CredentialsExpiredException、DisabledException、AccountExpiredException、LockedException。外部响应仍应避免枚举账户,内部只记录原因类别和关联 ID。错误转换。
预期故障推演:alice bind 成功但组 ACL 被删,读取对账单应拒绝,不能兜底 ROLE_USER 再让所有 authenticated 用户读财务。管理员修复只读组 ACL 后重新认证,检查目录组与最终 authority;若用户已持有旧会话,目录撤组不会自动刷新已保存 Authentication。应按后文的撤销窗口方案失效会话或查询映射版本。观测至少分离 search、bind、group 三段耗时/失败;看 LDAP 49、超时与零/多结果类别,不记录密码、完整 DN 列表或原始目录属性。
SAML:SP 信任 IdP,浏览器只负责搬运
SAML 中应用是 SP/RP,身份平台是 IdP/AP,浏览器是不可信消息载体。SP 生成 AuthnRequest 并保存请求 ID/关联状态;IdP 验证员工后给出签名 Response/Assertion,浏览器送到 ACS;SP 校验信任材料、签名、issuer、recipient/destination、audience、时间和请求关联,才创建本地身份。SAML 签名不等于“浏览器传来的 XML 都可信”,TLS 下载 metadata 也不等于对 metadata 本身做了签名验证。
本例添加 org.springframework.security:spring-security-saml2-service-provider。OpenSAML5 部分制品需要以下仓库;企业 Maven mirror 必须允许该仓库,否则即使 POM 写对也会解析失败。库依赖的 OpenSAML 小版本由实际 BOM/依赖树确认,不手工拼 OpenSAML4 和 5。固定依赖与架构。
本页每条替代链都是完整的独立链,都显式保留 /statements/** 的 hasRole("FINANCE_READ"),再进入 anyRequest().authenticated() 兜底;它们不会继承前一个配置的角色规则。SAML 默认转换器只给业务角色 ROLE_USER,因此认证成功但无财务角色时,对账单请求的预期结果是 403。下面为单一受信 IdP 添加教学 allowlist:IdP 契约以稳定 NameID employee-1001 表示 alice,只有该 issuer 与 subject 组合映射为财务只读角色;其余已验证身份仍只获 ROLE_USER。映射发生在签名、issuer、audience 等验证之后,不替代验证。真实组织应从受控账号/权限映射读取并处理停用及映射版本;不能把所有登录用户、任意 groups=admin 属性或可改显示名一律提权。默认角色、因子与转换扩展。
预期恢复对照:合法 IdP 的其他 subject 登录可成功但 /statements/1 仍为403;经授权确认后修正 alice 的稳定身份映射、重做登录,带 ROLE_FINANCE_READ 才通过该 HTTP 授权规则。撤销映射后需失效或刷新既有会话,不能仅修改配置就声称旧权限立即消失;错 audience 的签名断言仍应在认证层失败。AD 的目录组、MFA/Passkey/PreAuth 的本地 UserDetails 同样必须提供真实财务角色,补因子或换登录方式不会绕过共同角色要求。这些是配置与源码推演,未连接真实 IdP,也未以不存在的业务 Controller 响应冒称端到端结果。
<repositories>
<repository>
<id>shibboleth-releases</id>
<url>https://build.shibboleth.net/maven/releases/</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>下面的 Java 配置不依赖在线 metadata 自动发现,所有信任值显式列出。外部前提:IdP 已登记 SP entityID https://reports.example.test/saml/sp、ACS https://reports.example.test/login/saml2/sso/corp 与 SLO URL;IdP 支持签名 SSO/SLO,信任 SP 签名公钥;classpath 有 idp-signing.crt、sp-signing.crt、PKCS#8 未加密 RSA 教学私钥 sp-signing.key。私钥只放隔离工程并限制读取,生产应接入相应密钥治理。示例将同一 SP 凭据用于签名/解密以减少合成材料,真实系统可分开轮换。
package enterprise;
import java.io.InputStream;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.converter.RsaKeyConverters;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class SamlConfig {
private static X509Certificate certificate(String name) throws Exception {
try (InputStream input = new ClassPathResource(name).getInputStream()) {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(input);
}
}
@Bean
RelyingPartyRegistrationRepository registrations() throws Exception {
var idp = certificate("idp-signing.crt");
var sp = certificate("sp-signing.crt");
java.security.interfaces.RSAPrivateKey key;
try (InputStream input = new ClassPathResource("sp-signing.key").getInputStream()) {
key = RsaKeyConverters.pkcs8().convert(input);
}
var registration = RelyingPartyRegistration.withRegistrationId("corp")
.entityId("https://reports.example.test/saml/sp")
.assertionConsumerServiceLocation("https://reports.example.test/login/saml2/sso/corp")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation("https://reports.example.test/logout/saml2/slo")
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.signingX509Credentials(c -> c.add(Saml2X509Credential.signing(key, sp)))
.decryptionX509Credentials(c -> c.add(Saml2X509Credential.decryption(key, sp)))
.assertingPartyMetadata(p -> p
.entityId("https://idp.example.test/entity")
.singleSignOnServiceLocation("https://idp.example.test/sso")
.singleSignOnServiceBinding(Saml2MessageBinding.REDIRECT)
.wantAuthnRequestsSigned(true)
.singleLogoutServiceLocation("https://idp.example.test/slo")
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.verificationX509Credentials(c -> c.add(Saml2X509Credential.verification(idp))))
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Bean
SecurityFilterChain samlChain(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception {
var converter = new Saml2AuthenticationTokenConverter(
new DefaultRelyingPartyRegistrationResolver(registrations));
converter.setShouldConvertGetRequests(false);
var provider = new OpenSaml5AuthenticationProvider();
var identity = new OpenSaml5AuthenticationProvider.ResponseAuthenticationConverter();
identity.setGrantedAuthoritiesConverter(assertion -> {
boolean trustedEmployee = assertion.getIssuer() != null
&& "https://idp.example.test/entity".equals(assertion.getIssuer().getValue())
&& assertion.getSubject() != null && assertion.getSubject().getNameID() != null
&& "employee-1001".equals(assertion.getSubject().getNameID().getValue());
return trustedEmployee
? AuthorityUtils.createAuthorityList("ROLE_USER", "ROLE_FINANCE_READ")
: AuthorityUtils.createAuthorityList("ROLE_USER");
});
provider.setResponseAuthenticationConverter(identity);
provider.setAssertionValidator(OpenSaml5AuthenticationProvider.AssertionValidator.builder()
.clockSkew(java.time.Duration.ofSeconds(60)).build());
return http.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.saml2Login(s -> s.authenticationConverter(converter)
.authenticationManager(new ProviderManager(provider)))
.saml2Logout(Customizer.withDefaults())
.saml2Metadata(Customizer.withDefaults()).build();
}
}这里 corp 是仓库注册键,不是 IdP issuer,更不是租户权限。entityID/ACS/SLO 固定成外部 HTTPS URL,避免把未受信的转发头变成信任配置。另一合理配置是使用可信代理正确恢复 {baseUrl};这种方案必须封闭直连并限制 Forwarded 头来源。默认 SP entityID 模板为 {baseUrl}/saml2/service-provider-metadata/{registrationId},ACS 模板为 {baseUrl}/login/saml2/sso/{registrationId};本例覆盖它们。默认 AuthnRequest 入口为 /saml2/authenticate/{registrationId},通常 Redirect binding,默认签名算法 rsa-sha256,可由 IdP metadata 的要求调整;发送方式、签名要求、SP signing key 必须一致。请求配置。
Assertion 默认 clock skew 是 5 分钟,本例显式收紧为 60 秒。它是时钟容差,不是 session TTL,也不是“允许任何过期断言继续使用 5 分钟”的业务承诺。NTP 故障应修时钟并重新发起 SSO,不能无限扩大容差。
验签、解密、关联与返回类型
7.0.8 的处理器先验证 Response 签名;Response 已签名才解密其中加密断言,随后验证每个 Assertion 的签名再解密其元素、检查条件。必须签 Response 或签所有 Assertion;一个签过名的断言不能担保另一个未签断言。BaseOpenSamlAuthenticationProvider.process 的连续控制流如下,OpenSaml5 默认委托它执行。
固定原码:BaseOpenSamlAuthenticationProvider.java,连续 315–376 行。SHA-256 为整文件摘要,代码未省略或教学改写。
private void process(Saml2AuthenticationToken token, Response response) {
String issuer = issuer(response);
this.logger.debug(LogMessage.format("Processing SAML response from %s", issuer));
boolean responseSigned = response.isSigned();
ResponseToken responseToken = new ResponseToken(response, token);
Collection<Saml2Error> responseSignatureErrors = this.responseSignatureValidator.convert(responseToken)
.getErrors();
if (!responseSignatureErrors.isEmpty()) {
reportErrors(response, responseSignatureErrors);
return;
}
Collection<Saml2Error> errors = new ArrayList<>();
if (responseSigned) {
this.responseElementsDecrypter.accept(responseToken);
}
else if (!response.getEncryptedAssertions().isEmpty()) {
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
"Did not decrypt response [" + response.getID() + "] since it is not signed"));
}
if (!this.validateResponseAfterAssertions) {
errors.addAll(this.responseValidator.convert(responseToken).getErrors());
}
boolean allAssertionsSigned = true;
for (Assertion assertion : response.getAssertions()) {
AssertionToken assertionToken = new AssertionToken(assertion, token);
Collection<Saml2Error> assertionSignatureErrors = this.assertionSignatureValidator.convert(assertionToken)
.getErrors();
errors.addAll(assertionSignatureErrors);
allAssertionsSigned = allAssertionsSigned && assertion.isSigned();
if (!assertionSignatureErrors.isEmpty()) {
continue;
}
if (responseSigned || assertion.isSigned()) {
this.assertionElementsDecrypter.accept(new AssertionToken(assertion, token));
}
errors.addAll(this.assertionValidator.convert(assertionToken).getErrors());
}
if (!responseSigned && !allAssertionsSigned) {
String description = "Either the response or one of the assertions is unsigned. "
+ "Please either sign the response or all of the assertions.";
errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, description));
}
if (this.validateResponseAfterAssertions) {
errors.addAll(this.responseValidator.convert(responseToken).getErrors());
}
else {
Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions());
if (firstAssertion != null && !hasName(firstAssertion)) {
errors.add(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
"Assertion [" + firstAssertion.getID() + "] is missing a subject"));
}
}
reportErrors(response, errors);
}
private void reportErrors(Response response, Collection<Saml2Error> errors) {
if (errors.isEmpty()) {
if (this.logger.isDebugEnabled()) {errors 收集失败,末端抛 Saml2AuthenticationException;不能拿“XML 已解析”当成功标志。验签公钥来自已配置的 IdP trust material;OpenSaml5 Provider 的 Javadoc 明确不为该验签证书执行 X.509 certificate validation,因此其有效性、可信取得和撤销由配置治理负责,不能把任意用户上传的证书加进可信集合。Provider 约定。
默认请求仓库是 HttpSessionSaml2AuthenticationRequestRepository,响应中出现 InResponseTo 时,必须找到已保存请求且 ID 相等。反过来,InResponseTo 缺失时这个检查直接成功;因此“默认只接受 SP 主动发起 SSO”是错误结论。要仅允许 SP 发起,应用应在保留默认验证基础上额外要求关联请求存在,并拒绝未经关联的 IdP 主动登录;不能通过删除 InResponseTo 校验来修复丢 cookie。
固定原码:BaseOpenSamlAuthenticationProvider.java,连续 230–254 行。SHA-256 为整文件摘要,代码未省略或教学改写。
static Saml2ResponseValidatorResult validateInResponseTo(AbstractSaml2AuthenticationRequest storedRequest,
String inResponseTo) {
if (!StringUtils.hasText(inResponseTo)) {
return Saml2ResponseValidatorResult.success();
}
if (storedRequest == null) {
String message = "The response contained an InResponseTo attribute [" + inResponseTo + "]"
+ " but no saved authentication request was found";
return Saml2ResponseValidatorResult
.failure(new Saml2Error(Saml2ErrorCodes.INVALID_IN_RESPONSE_TO, message));
}
if (!inResponseTo.equals(storedRequest.getId())) {
String message = "The InResponseTo attribute [" + inResponseTo + "] does not match the ID of the "
+ "authentication request [" + storedRequest.getId() + "]";
return Saml2ResponseValidatorResult
.failure(new Saml2Error(Saml2ErrorCodes.INVALID_IN_RESPONSE_TO, message));
}
return Saml2ResponseValidatorResult.success();
}
static Converter<AssertionToken, Saml2ResponseValidatorResult> createDefaultAssertionValidator() {
return createDefaultAssertionValidatorWithParameters(
(params) -> params.put(SAML2AssertionValidationParameters.CLOCK_SKEW, Duration.ofMinutes(5)));
}一个真实失败输入是:浏览器先得到 request _r1,返回时跨站 POST 不带原会话 cookie,但 Response 带 InResponseTo="_r1"。预期错误为 INVALID_IN_RESPONSE_TO,不是“IdP 密码错误”。检查 SameSite/HTTPS/代理域、session 存储及多标签覆盖后,重新生成请求;不要重放旧 Response。若改用 RelayState 分布式缓存关联,必须另加浏览器握手绑定、随机且不可预测的 state、限时和一次消费;官方说明仅依赖攻击者可搬运的 RelayState/InResponseTo 找缓存,会失去原会话绑定的登录固定防御。请求仓库与缓存边界。
SAML assertion 中 audience 对应本 SP entityID,Recipient 对应 ACS,issuer 对应配置 IdP;有签名但 audience 是 https://other.example.test/sp 仍须拒绝。默认没有共享重放缓存来实现 <OneTimeUse>:AssertionValidator 的默认 OneTimeUse validator 是放行占位;需要此约束时添加具有共享、原子写入能力的 OpenSAML OneTimeUseConditionValidator,并在多节点测试同一 assertion ID 竞争。不能只看单次 HttpSession request 删除就宣称全企业断言不可重放。默认校验构造。
7.0.8 默认转换以第一条断言生成身份,保留属性、NameID、session indexes 和 registrationId,追加 FACTOR_SAML_RESPONSE,返回 Saml2AssertionAuthentication。默认业务角色仅 ROLE_USER,不能把 groups=admin 未经 allowlist 映射就变为 ROLE_ADMIN;读取多值属性时不能只拿第一项而忽略冲突。自定义 ResponseAuthenticationConverter#setPrincipalNameConverter、setGrantedAuthoritiesConverter 可以映射稳定员工号与授权,但必须保留断言数据供 SLO 使用。当前转换器。旧 Saml2AuthenticatedPrincipal API 在 7 被弃用,迁移不能只机械照搬强转。
版本陷阱:固定 7.0.8 的 whats-new 写了移除 GET SAMLResponse,但实际 Saml2AuthenticationTokenConverter 的 shouldConvertGetRequests 默认为 true,迁移页说 8 才改默认。本例显式设 false,故不能从版本摘要推导“7.0.8 所有 GET 默认拒绝”。Converter 还需配合实际过滤器 matcher,不能只凭一个字段推导任意 URL 都接受 GET。实际默认与转换、迁移说明。
Metadata、多 IdP 与单点退出
手工配置换成 metadata 的方案是 RelyingPartyRegistrations.fromMetadataLocation("classpath:corp-idp.xml").registrationId("corp"),随后继续配置 SP entityID/ACS、签名和解密凭据再 build;不要同时保留一个含相同 registrationId 的第二仓库。可信的本地 metadata 让启动不依赖在线 IdP,代价是更新责任在发布流程;直接从 HTTPS metadata URL 初始化会在解析时访问外部服务,其不可达可能使启动失败。需要定期刷新时使用 CachingRelyingPartyRegistrationRepository 的缓存策略,或 OpenSaml5AssertingPartyMetadataRepository 的 expiry-aware resolver。刷新不等于验签:后者未配置 verification credentials 就不验证 metadata 签名,新验签根必须通过既有受信途径进入。固定 metadata 文档。
saml2Metadata 发布 /saml2/metadata,还支持 /saml2/metadata/{registrationId}、/saml2/service-provider-metadata/{registrationId}。它导出 SP 公共信息供 IdP 登记,不应输出私钥。多个 IdP 各有 RelyingPartyRegistration;注册项查找与 tenant 绑定应由受信配置决定,不能接收浏览器提交任意 metadata URL 再自动信任。数据库保存 metadata/registration 是存储选择,不意味着框架自带完整 JDBC 管理与轮换事务;自定义 repository 还要保证同一次请求使用一致快照。各字段意义与 URL 模板见注册配置。
本例同时启用了 saml2Logout,并为 RP/AP 各配置 SLO 地址和签名材料。RP 发起的 POST /logout 先完成本地退出,再发签名 LogoutRequest;浏览器未返回、IdP 故障或 LogoutResponse 验证失败不应恢复本地会话。AP 发起的请求先验证再调用本地 LogoutHandler。其失败不能概括成“一律 401 并注销”:registration 解析失败可能 400;签名等验证异常时尝试发送错误 LogoutResponse,不能生成才返回 401;验证成功以后才执行 logout。固定过滤器连续原码如下。
固定原码:Saml2LogoutRequestFilter.java,连续 116–158 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
Saml2LogoutRequestValidatorParameters parameters;
try {
parameters = this.logoutRequestResolver.resolve(request, authentication);
}
catch (Saml2AuthenticationException ex) {
this.logger.trace("Did not process logout request since failed to find requested RelyingPartyRegistration");
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
if (parameters == null) {
chain.doFilter(request, response);
return;
}
try {
validateLogoutRequest(request, parameters);
}
catch (Saml2AuthenticationException ex) {
Saml2LogoutResponse errorLogoutResponse = this.logoutResponseResolver.resolve(request, authentication, ex);
if (errorLogoutResponse == null) {
this.logger.trace(LogMessage.format(
"Returning error since no error logout response could be generated: %s", ex.getSaml2Error()));
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
sendLogoutResponse(request, response, errorLogoutResponse);
return;
}
this.handler.logout(request, response, authentication);
Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolve(request, authentication);
if (logoutResponse == null) {
this.logger.trace("Returning error since no logout response generated");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
sendLogoutResponse(request, response, logoutResponse);
}因此测试输入应分成“无法定位注册项”“能定位但签名错误”“合法 LogoutRequest”“合法请求但回包失败”;分别检查响应、旧会话和 IdP 状态。只看到浏览器 200/302 不能证明 SLO 全链完成。若要区分本地退出和 SLO,可为 saml2Logout 单独设置启动 URL,并与普通 logout URL 分开;改变接收 SLO URL 时同步修改 RelyingPartyRegistration 和 IdP 登记,不能只改 DSL。LogoutRequest 默认保存在 session 中用于验证返回关联;共享/自定义存储同样需要时效、一次性和会话绑定。退出角色与配置。
旧 SAML Extensions 1.x 的 filter/metadata 管理不能无损替换为当前 DSL。7 主线使用 saml2Login/saml2Logout/saml2Metadata、内建 registration 多租户;不提供企业 IdP,也不能把旧 Artifact binding 或 Response 的 Redirect binding 支持直接算进当前能力。固定迁移边界。
X.509:TLS 先建立证书信任,Security 再映射账号
mTLS 有两层检查。容器先验证客户端是否证明持有私钥、证书链是否可信、有效期及已配置的撤销策略;握手成功后,Security 从容器提供的证书对象提取 CN 或 email,查找本地活动账号并加载权限。TLS 证书有效但账号被停用仍应拒绝;应用中匹配到 CN=alice 也不能弥补未知 CA。
共同依赖已包含 Servlet X.509,无须再找一个 spring-security-x509 artifact。独立工程的 Boot TLS 前提示例如下,密钥库包含服务端私钥/证书链,信任库只含允许签发客户端证书的 CA;need 表示必须提供证书。这里不是已有 TLS 运行结果。
server.port=8443
server.ssl.enabled=true
server.ssl.key-store=classpath:server.p12
server.ssl.key-store-type=PKCS12
server.ssl.key-store-password=${SERVER_KEYSTORE_PASSWORD}
server.ssl.trust-store=classpath:client-ca.p12
server.ssl.trust-store-type=PKCS12
server.ssl.trust-store-password=${CLIENT_TRUSTSTORE_PASSWORD}
server.ssl.client-auth=need此配置及后面的 pre-auth、MFA、passkey、CAS 示例,需要一个本地账号/权限来源。下面是可复用的合成账号配置,每个独立工程只引入一次;用 enterprise.subject=alice,Kerberos 例子改为 alice@EXAMPLE.TEST。enterprise.password-hash=${ALICE_PASSWORD_HASH} 必须提供合成密码的有效编码值,密码编码机制回查 U29。本例在加载处检查账号状态,使后面 OTT/passkey 的账号读取也拒绝禁用身份;企业系统应替换为具有同样状态契约的实际用户源,不把静态表当账号治理方案。
package enterprise;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration(proxyBeanMethods = false)
public class EnterpriseAccounts {
@Bean
UserDetailsService enterpriseUsers(
@Value("${enterprise.subject:alice}") String subject,
@Value("${enterprise.password-hash}") String encodedPassword) {
var users = new InMemoryUserDetailsManager(User.withUsername(subject)
.password(encodedPassword).roles("FINANCE_READ").build());
var checker = new AccountStatusUserDetailsChecker();
return name -> {
var user = users.loadUserByUsername(name);
checker.check(user);
return user;
};
}
}package enterprise;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor;
@Configuration(proxyBeanMethods = false)
public class X509Config {
@Bean
SecurityFilterChain certificateChain(HttpSecurity http, UserDetailsService users) throws Exception {
var extractor = new SubjectX500PrincipalExtractor();
extractor.setExtractPrincipalNameFromEmail(false);
return http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.x509(x -> x.x509PrincipalExtractor(extractor).userDetailsService(users)).build();
}
}7.0 默认 SubjectX500PrincipalExtractor 按 RFC2253 的 RDN 解析、从最具体 RDN 开始找 CN;本例显式关闭 email 提取。需要 email 时设 setExtractPrincipalNameFromEmail(true),但应事先规定如何与内部账号唯一绑定。旧 subjectPrincipalRegex 仍存在但 deprecated;不能按旧正则默认解释 7.0。实际 Servlet 过滤器原码如下,它读取的是 jakarta.servlet.request.X509Certificate 属性,不是浏览器传来的 X-Client-Cert header。
固定原码:X509AuthenticationFilter.java,连续 30–61 行。SHA-256 为整文件摘要,代码未省略或教学改写。
*
* @author Luke Taylor
*/
public class X509AuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
private X509PrincipalExtractor principalExtractor = new SubjectX500PrincipalExtractor();
@Override
protected @Nullable Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
X509Certificate cert = extractClientCertificate(request);
return (cert != null) ? this.principalExtractor.extractPrincipal(cert) : null;
}
@Override
protected @Nullable Object getPreAuthenticatedCredentials(HttpServletRequest request) {
return extractClientCertificate(request);
}
private @Nullable X509Certificate extractClientCertificate(HttpServletRequest request) {
X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
if (certs != null && certs.length > 0) {
this.logger.debug(LogMessage.format("X.509 client authentication certificate:%s", certs[0]));
return certs[0];
}
this.logger.debug("No client certificate found in request.");
return null;
}
public void setPrincipalExtractor(X509PrincipalExtractor principalExtractor) {
this.principalExtractor = principalExtractor;
}Servlet X509Configurer 建立 PreAuthenticatedAuthenticationProvider、设置 FACTOR_X509 supplier,默认把成功 Context 存入 RequestAttributeSecurityContextRepository;这与直接 new 一个 pre-auth filter 的默认 HttpSession repository 不同。装配源码。固定 Servlet 文档夹入了 Reactive manager 说明,本文以此 Servlet 装配为准。Reactive 使用 SslInfo 中的 peer cert 与 ReactivePreAuthenticatedAuthenticationManager,其默认权限结果不能照搬成自动拥有 X509 因子;这部分配置归响应式正文。Reactive 转换器、默认 manager。
预期验证:无证书、未知 CA 或过期证书通常在 TLS 层失败,没有业务 HTTP 状态可看;受信证书 CN=ghost 通过握手但本地无账号时在 Security 层拒绝;受信 CN=alice 且有角色才允许对账单读取。可在隔离工程分别用 curl --cacert server-ca.crt --cert alice.crt --key alice.key https://reports.example.test:8443/statements/1 与去掉/替换 client cert 比较。这些命令需已签发材料且尚未实跑。恢复时修 CA 链/账号映射后重建 TLS 连接再试,不能把旧连接复用成功当新证书生效;CRL/OCSP、TLS 会话恢复和已有应用会话的撤销是独立责任。只配置 trust-store 不自动等于即时证书撤销。
MFA 与 OTT:机制成功、因子合并和授权新鲜度
密码加邮件 OTT 是本页的教学组合。密码是“知道什么”,邮件通道能否成为独立因素取决于邮箱账号及恢复渠道是否独立;两次输入同一个密码、两张同一设备可导出的证书,不会因为名字不同就自动满足两类独立因素。Spring 的 FACTOR_* 记录认证方式及 issuedAt,不替组织评估因素强度。
仅配置 formLogin 和 oneTimeTokenLogin,默认可以二选一登录。7.0 用 @EnableMultiFactorAuthentication 开启因子合并支持,再通过 AuthorizationManagerFactory/hasAllAuthorities 要求足够证明。全局注解的 authorities={PASSWORD_AUTHORITY, OTT_AUTHORITY} 为由该工厂生成的授权规则增加因子要求;本例采用空 authorities 的注解,只为 /adjustments/** 选择性要求近期两个因子,普通对账单仍可先使用密码。需绕过/自定义的 .access(...) 规则应单独评审,不能假定任意自写 AuthorizationManager 都被全局策略改造。固定 MFA 文档。
OTT 是服务器生成、投递、保存、消费的随机 bearer token;它不是 TOTP/HOTP 的共享密钥与时间/计数算法。共同依赖已有 OTT;下例为了给出真实投递装配,再添加 org.springframework.boot:spring-boot-starter-mail,使用隔离 SMTP 参数 spring.mail.host/port/username/password、按该 SMTP 服务的要求设置 TLS。示例邮件只发往预先登记的合成收件地址,绝不把请求中的任意 email 当作目标。外部 SMTP 与浏览器域名是部署前提,本文未发送邮件。
package enterprise;
import java.time.Duration;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
import org.springframework.security.authentication.ott.OneTimeTokenService;
import org.springframework.security.authorization.AuthorizationManagerFactories;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver;
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import org.springframework.web.util.UriComponentsBuilder;
@Configuration(proxyBeanMethods = false)
@EnableMultiFactorAuthentication(authorities = {})
public class MfaConfig {
@Bean
OneTimeTokenService oneTimeTokens() {
return new InMemoryOneTimeTokenService();
}
@Bean
GenerateOneTimeTokenRequestResolver tokenRequestResolver() {
var resolver = new DefaultGenerateOneTimeTokenRequestResolver();
resolver.setExpiresIn(Duration.ofMinutes(5));
return resolver;
}
@Bean
OneTimeTokenGenerationSuccessHandler tokenDelivery(JavaMailSender mail) {
Map<String, String> addresses = Map.of("alice", "alice@example.test");
var redirect = new RedirectOneTimeTokenGenerationSuccessHandler("/login?ottSent");
return (request, response, token) -> {
String address = addresses.get(token.getUsername());
if (address != null) {
String link = UriComponentsBuilder.fromUriString("https://reports.example.test/login/ott")
.queryParam("token", token.getTokenValue()).build().toUriString();
var message = new SimpleMailMessage();
message.setFrom("security@example.test");
message.setTo(address);
message.setSubject("合成企业登录链接");
message.setText("五分钟内打开并提交登录表单:" + link);
mail.send(message);
}
redirect.handle(request, response, token);
};
}
@Bean
SecurityFilterChain mfaChain(HttpSecurity http, OneTimeTokenService oneTimeTokens) throws Exception {
var recent = AuthorizationManagerFactories.multiFactor()
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(15)))
.requireFactor(f -> f.ottAuthority().validDuration(Duration.ofMinutes(5)))
.build();
return http.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/login/ott", "/ott/generate").permitAll()
.requestMatchers("/adjustments/**").access(recent.hasRole("FINANCE_READ"))
.requestMatchers("/statements/**").hasRole("FINANCE_READ")
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(o -> o.tokenService(oneTimeTokens)).build();
}
}使用前一节的 EnterpriseAccounts,不静态填入 FACTOR_PASSWORD/OTT。默认生成入口 POST /ott/generate、参数 username,默认页 GET /login/ott,消费入口 POST /login/ott、参数 token,TTL 默认 5 分钟;本例显式同样的 TTL。GET magic link 只是预填页面,POST 才消费,CSRF 保持启用。生成与消费 URL 可以用 tokenGeneratingUrl、loginProcessingUrl 改,默认提交页可通过 showDefaultSubmitPage(false) 禁用并提供自己的 GET 页面;自定义页面仍须提交正确 processing URL/CSRF。需要不同链使用不同服务时,用 .tokenService(service) 显式隔离,不能误用唯一全局 Bean。OTT 页面、投递与定制、Configurer API。
该投递例是同步 SMTP 的最小配置。投递失败发生在 token 生成之后,不能把“已生成”记作“已送达”。生产方案需要限流、统一不枚举账号的外部响应、受控重发/旧 token 撤销与投递可靠性;异步队列还需处理过期后才投递、重复发送和 secret 脱敏。默认生成服务不会因用户名不存在就自动阻止分配 token,因此账户/IP/全局速率限制必须位于生成入口前。含 token 的 URL 不应进入访问日志、Referer、第三方分析或邮件点击跟踪;反代和观测配置也要遵守这个边界。
状态机与临界时刻
预期状态为:无身份 → 密码认证完成且带 PASSWORD → 请求资金调整时缺 OTT → 跳转补证 → 同一身份 OTT 成功后合并因子 → 同时检查角色与新鲜度。若第二次认证是另一用户,不能把 alice 的密码和 bob 的 OTT 合成高权限身份。7.0 的 AbstractAuthenticationProcessingFilter 只有在 MFA 已启用、当前已认证、新结果声明支持 toBuilder、且 getName() 相等时才合并权限;同名也不等于跨 IdP 相同人,账户归一化必须在前面的身份映射边界完成。合并与守卫。
下面是 AllRequiredFactorsAuthorizationManager.requiredFactorError 的连续原码。没有 validDuration 时只比 authority 字符串;配置了 duration 才要求实际 FactorGrantedAuthority 中的 issuedAt,并以严格 now < issuedAt + duration 放行。
固定原码:AllRequiredFactorsAuthorizationManager.java,连续 99–124 行。SHA-256 为整文件摘要,代码未省略或教学改写。
private @Nullable RequiredFactorError requiredFactorError(RequiredFactor requiredFactor,
List<GrantedAuthority> currentFactors) {
Optional<GrantedAuthority> matchingAuthority = currentFactors.stream()
.filter((authority) -> Objects.equals(authority.getAuthority(), requiredFactor.getAuthority()))
.findFirst();
if (!matchingAuthority.isPresent()) {
return RequiredFactorError.createMissing(requiredFactor);
}
return matchingAuthority.map((authority) -> {
if (requiredFactor.getValidDuration() == null) {
// granted (only requires authority to match)
return null;
}
else if (authority instanceof FactorGrantedAuthority factorAuthority) {
Instant now = this.clock.instant();
Instant expiresAt = factorAuthority.getIssuedAt().plus(requiredFactor.getValidDuration());
if (now.isBefore(expiresAt)) {
// granted
return null;
}
}
// denied (expired or no issuedAt to compare)
return RequiredFactorError.createExpired(requiredFactor);
}).orElse(null);
}输入密码因子 issuedAt=10:00、OTT issuedAt=10:10,本例在 10:14:59 可满足两个窗口;10:15:00 两个窗口都到边界,应拒绝资金调整并补证,普通已认证资源不必全部退出。把数据库角色列写成 FACTOR_OTT 只能欺骗没有新鲜度的字符串检查,不能生成这次真实认证的证明;有 duration 时 SimpleGrantedAuthority 即使同名也按过期处理。角色与因子不应共用一个可由业务管理员随意填写的授权命名空间。
动态启用 MFA 时,MapRequiredAuthoritiesRepository 的 saveRequiredAuthorities("alice", List.of(...)) 与 RequiredAuthoritiesAuthorizationManager 可按用户查询要求,再用 DefaultAuthorizationManagerFactory#setAdditionalAuthorization 组合到授权规则。内存表适合说明,不是跨实例策略源;自定义 RequiredAuthoritiesRepository 应区分“用户未开启”和“策略库故障”,故障不能变成空列表放行。需要按风险/Authentication 决定时写自定义 AuthorizationManager,并将失败语义、时钟、升级入口和缓存版本明示。hasAllAuthorities 适合少量静态规则,但它本身没有 duration;其他 scope 的重新授权要配置对应缺失 authority 的 AuthenticationEntryPoint,并使用受信的 OAuth 客户端注册,不能由浏览器传任意授权端点。动态 MFA 与 re-authentication。
OTT 的一次性与存储失败
内存服务保存 token→用户名/到期时刻,consume 先 remove,再检查过期。因此一个 token 由一个 JVM 内的 ConcurrentHashMap 原子删除裁决,重复请求至多一个得到非空结果。过期清理只影响占用,不代替消费时检查;源码在 token 数量至少 100 时触发扫描,不是按期精确清理任务。
固定原码:InMemoryOneTimeTokenService.java,连续 29–76 行。SHA-256 为整文件摘要,代码未省略或教学改写。
/**
* Provides an in-memory implementation of the {@link OneTimeTokenService} interface that
* uses a {@link ConcurrentHashMap} to store the generated {@link OneTimeToken}. A random
* {@link UUID} is used as the token value. A clean-up of the expired tokens is made if
* there is more or equal than 100 tokens stored in the map.
*
* @author Marcus da Coregio
* @since 6.4
*/
public final class InMemoryOneTimeTokenService implements OneTimeTokenService {
private final Map<String, OneTimeToken> oneTimeTokenByToken = new ConcurrentHashMap<>();
private Clock clock = Clock.systemUTC();
@Override
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
String token = UUID.randomUUID().toString();
Instant expiresAt = this.clock.instant().plus(request.getExpiresIn());
OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), expiresAt);
this.oneTimeTokenByToken.put(token, ott);
cleanExpiredTokensIfNeeded();
return ott;
}
@Override
public @Nullable OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) {
OneTimeToken ott = this.oneTimeTokenByToken.remove(authenticationToken.getTokenValue());
if (ott == null || isExpired(ott)) {
return null;
}
return ott;
}
private void cleanExpiredTokensIfNeeded() {
if (this.oneTimeTokenByToken.size() < 100) {
return;
}
for (Map.Entry<String, OneTimeToken> entry : this.oneTimeTokenByToken.entrySet()) {
if (isExpired(entry.getValue())) {
this.oneTimeTokenByToken.remove(entry.getKey());
}
}
}
private boolean isExpired(OneTimeToken ott) {
return this.clock.instant().isAfter(ott.getExpiresAt());
}这里过期比较是 now.isAfter(expiresAt),恰好等于到期时刻不在此分支判过期,和 MFA 的边界不同。不要把“5 分钟”偷换成两种实现完全相同的闭区间。进程重启丢 token、节点 B 找不到节点 A 的 token 是内存方案的确定边界;再次投递应生成新的 token,而不是把已消费记录复活。
共享 JDBC 可使用 new JdbcOneTimeTokenService(jdbcOperations) 作为 OneTimeTokenService Bean,添加 Boot JDBC starter/驱动、配置 DataSource,并先建对应表。源码表包含主键 token_value varchar(36)、username 与 expires_at,默认脚本的 varchar_ignorecase 不能不改方言就当 MySQL DDL;MySQL 可选择 username varchar(50) 并明确大小写/collation 与账号规范一致。setCleanupCron 定义清理频率,默认 @hourly;超时拒绝仍发生在 consume,不能用清理每小时解释有效期每小时。官方 schema。
JDBC consume 先 SELECT 再 DELETE,用删除影响行数保证竞争者不能都成功。它不是要求开发者推测“两个 SQL 必然在一个框架事务里”;返回 token 前必须成功删除,且不能把被外部事务回滚后的 token 消费算成持久成功。
固定原码:JdbcOneTimeTokenService.java,连续 131–151 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
Assert.notNull(request, "generateOneTimeTokenRequest cannot be null");
String token = UUID.randomUUID().toString();
Instant expiresAt = this.clock.instant().plus(request.getExpiresIn());
OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt);
insertOneTimeToken(oneTimeToken);
return oneTimeToken;
}
private void insertOneTimeToken(OneTimeToken oneTimeToken) {
List<SqlParameterValue> parameters = this.oneTimeTokenParametersMapper.apply(oneTimeToken);
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
this.jdbcOperations.update(SAVE_ONE_TIME_TOKEN_SQL, pss);
}
@Override
public @Nullable OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) {
Assert.notNull(authenticationToken, "authenticationToken cannot be null");
List<OneTimeToken> tokens = selectOneTimeToken(authenticationToken);如果 SELECT 后数据库不可用,认证失败;DELETE 已提交但响应丢失,用户重试得到无效 token,恢复是重新申请,不保证“同一个 token 重试成功”。若消费后加载账号失败,token 也已消耗。7.0.8 OTT Provider 在消费后调用 UserDetailsService 并追加 FACTOR_OTT,本身没有像 DAO/PreAuth 一样执行 AccountStatusUserDetailsChecker,所以本页的账号服务显式检查状态;不要假定所有 Provider 的停用检查相同。OTT Provider。
Reactive OTT 有独立 manager/filter/service,7.0.8 的 Reactive manager 直接使用用户权限,不能推导其自动追加 Servlet FACTOR_OTT,更不能从“支持 OTT”推导“支持 MFA 注解”。本版本注解 Javadoc 明确不支持 Reactive,故本节完整 MFA 配置限定 Servlet。注解限制、Reactive OTT 实现。
Passkeys:注册绑定、公钥断言与设备丢失
WebAuthn 把私钥留在认证器,服务端保存 credential ID、公钥/注册数据、user handle、签名计数和所属内部用户。注册必须先通过已有可信身份,不能只提交 username=alice 就给 alice 新增钥匙。认证时服务端发随机 challenge,浏览器/认证器签名;服务端用预存公钥验证 challenge、RP ID、origin、用户验证要求和签名,再映射本地账号。同步 passkey 的恢复信任还依赖认证器/平台账号,不等于一把永不导出的物理密钥。固定 passkeys 文档。
本例添加 org.springframework.security:spring-security-webauthn,沿用 EnterpriseAccounts。HTTPS 页面位于 https://reports.example.test,RP ID 明确为 reports.example.test,allowedOrigins 包括 scheme/host/port 的精确 origin;RP ID 不是包含 https:// 的 URL。DNS、HTTPS 证书和桌面浏览器认证器是前提。下面显式选择内存仓库,并将默认 preferred 改为 required,注册和认证两类 options 都改,才能要求客户端用户验证;这不等于强制“生物识别”这种特定方式,PIN 也可能满足 UV。
package enterprise;
import java.util.Set;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
@Configuration(proxyBeanMethods = false)
public class PasskeyConfig {
@Bean
PublicKeyCredentialUserEntityRepository passkeyUsers() {
return new MapPublicKeyCredentialUserEntityRepository();
}
@Bean
UserCredentialRepository passkeyCredentials() {
return new MapUserCredentialRepository();
}
@Bean
WebAuthnRelyingPartyOperations relyingParty(
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
var operations = new Webauthn4JRelyingPartyOperations(users, credentials,
PublicKeyCredentialRpEntity.builder().id("reports.example.test").name("合成对账平台").build(),
Set.of("https://reports.example.test"));
operations.setCustomizeCreationOptions(o -> o.authenticatorSelection(
AuthenticatorSelectionCriteria.builder().residentKey(ResidentKeyRequirement.REQUIRED)
.userVerification(UserVerificationRequirement.REQUIRED).build()));
operations.setCustomizeRequestOptions(o -> o.userVerification(UserVerificationRequirement.REQUIRED));
return operations;
}
@Bean
SecurityFilterChain passkeyChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn(w -> w.rpId("reports.example.test").rpName("合成对账平台")
.allowedOrigins("https://reports.example.test")).build();
}
}当自定义 WebAuthnRelyingPartyOperations Bean 存在时,操作对象中的 RP/origins 是关键校验来源,应与 DSL 一致;不能只改 DSL,却继续使用旧 origin 的自定义 operations。Configurer 的选择逻辑。
预期浏览器顺序:先密码登录 → 访问默认注册页 /webauthn/register → 携 CSRF POST /webauthn/register/options 为当前用户创建 options → navigator.credentials.create → 携 CSRF POST /webauthn/register 注册公钥。注销后 POST /webauthn/authenticate/options → navigator.credentials.get → POST /login/webauthn 验证 assertion。JSON 中 challenge、rawId、user.id 等二进制字段要按 WebAuthn 协议做 base64url 编解码,不能把教学字符串当有效签名。默认页面已做这些工作,本例无需再手写一套容易不一致的前端。注册与认证请求形状。
默认 creation/request options 的 timeout 为 5 分钟、UV preferred、注册 attestation none、resident key required;本例只改变 UV。timeout 是提供给客户端的选项,不应直接当作服务端 challenge 的独立 TTL:options 默认存在 HttpSession,服务端实际拒绝过期会话/缺 options 的时点要看 repository 与 session 策略。认证过滤器先取 options,再清除,随后才调用 manager。
固定原码:WebAuthnAuthenticationFilter.java,连续 88–123 行。SHA-256 为整文件摘要,代码未省略或教学改写。
private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository = new HttpSessionPublicKeyCredentialRequestOptionsRepository();
public WebAuthnAuthenticationFilter() {
super(pathPattern(HttpMethod.POST, "/login/webauthn"));
setSecurityContextRepository(new HttpSessionSecurityContextRepository());
setAuthenticationFailureHandler(
new AuthenticationEntryPointFailureHandler(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
setAuthenticationSuccessHandler(new HttpMessageConverterAuthenticationSuccessHandler());
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
ServletServerHttpRequest httpRequest = new ServletServerHttpRequest(request);
ResolvableType resolvableType = ResolvableType.forClassWithGenerics(PublicKeyCredential.class,
AuthenticatorAssertionResponse.class);
PublicKeyCredential<AuthenticatorAssertionResponse> publicKeyCredential = null;
try {
publicKeyCredential = (PublicKeyCredential<AuthenticatorAssertionResponse>) this.converter
.read(resolvableType, httpRequest, null);
}
catch (Exception ex) {
throw new BadCredentialsException("Unable to authenticate the PublicKeyCredential", ex);
}
PublicKeyCredentialRequestOptions requestOptions = this.requestOptionsRepository.load(request);
if (requestOptions == null) {
throw new BadCredentialsException(
"Unable to authenticate the PublicKeyCredential. No PublicKeyCredentialRequestOptions found.");
}
this.requestOptionsRepository.save(request, response, null);
RelyingPartyAuthenticationRequest authenticationRequest = new RelyingPartyAuthenticationRequest(requestOptions,
publicKeyCredential);
WebAuthnAuthenticationRequestToken token = new WebAuthnAuthenticationRequestToken(authenticationRequest);
return getAuthenticationManager().authenticate(token);
}因此“错签名后重试同一个 challenge”预期失败,恢复应重新取 options。多标签窗口中一次新 options 可能覆盖同一 session 的旧值;默认 session 的 load/save(null) 也不能证明集群上有原子 get-and-delete。验证成功后 Webauthn4JRelyingPartyOperations 更新 lastUsed/signatureCount,失败写回或并发覆盖要按仓库一致性处理,不能通过取消签名/计数检查修复。服务端验证与写回。WebAuthn Provider 追加 FACTOR_WEBAUTHN,但账号状态同样要由受控 UserDetailsService/认证层保证,不能把“已存公钥”当作用户永远在职。认证 Provider。
需要持久化时,在同一配置中把两个 Map 仓库换成 JdbcPublicKeyCredentialUserEntityRepository(jdbc) 与 JdbcUserCredentialRepository(jdbc),增加 JDBC starter/驱动/数据库 schema;只替其中一个会出现用户映射存在但凭据丢失、或凭据指向不存在 user handle。schema 的 user-entities 与 user-credentials 两张表都要按数据库方言审读,并让内部用户 ID/credential ID 唯一约束有效。自定义仓库必须保留相同身份绑定和更新语义;备份恢复不得复活已经撤销的凭据。JDBC 仓库、user entity 仓库。
creation options repository 可以通过 Bean 或 .creationOptionsRepository(...) 替换;只有存取一致、绑定当前认证用户、限制寿命且成功/失败消费策略明确才安全。messageConverter(...) 定制注册 JSON 时应保留二进制及类型验证;7 默认路径已面向 Jackson 3/SmartHttpMessageConverter,不能照抄旧 Generic converter 的 API 后声称一定兼容。扩展 API。
设备丢失时,通过已登记备用凭据或受控企业恢复程序重新证明身份,再撤旧 credential、失效其既有 session 并重查恢复结果。不能只用“知道用户名”或临时改为 preferred 就允许注册。删除也必须校验 owner:单独 new WebAuthnRegistrationFilter 默认 deny,但完整 WebAuthn DSL 会装配 CredentialRecordOwnerAuthorizationManager,所以“删除默认全开/全拒”都忽略了实际装配。DSL 所有者检查、删除授权执行。预期失败样本包括更换为未允许 origin、RP ID 不匹配、错误签名、无 session challenge、删除他人的 credential;修复配置/重新取 challenge 后,合法身份成功而原失败样本仍拒绝,才算恢复。
预认证:Header、请求属性与容器身份的可信入口
pre-auth 的语义是“上游已经可靠认证,本层提取身份并补齐账号/权限”。RequestHeaderAuthenticationFilter 默认读 SM_USER,没有密码/签名验证;credentials 默认只是 N/A。攻击者直连后端并提交 SM_USER: alice 时,若网络拓扑允许且应用照单接收,就可能得到 alice 身份。密码很强也不能挽救这个绕过入口。官方预认证约定。
以下完整链沿用共同依赖与 EnterpriseAccounts,前提是后端只接受受信认证代理连接,代理删除外部请求自带 SM_USER 并重写为其认证结果,身份头不被不可信中间层覆盖。应用本身没有执行这些网络措施,配置不能替代前提。filter 在方法内创建,避免 Boot 把 Filter Bean 自动注册进容器后又在 Security 链运行一次。
package enterprise;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
@Configuration(proxyBeanMethods = false)
public class PreAuthConfig {
@Bean
SecurityFilterChain upstreamChain(HttpSecurity http, UserDetailsService users) throws Exception {
var provider = new PreAuthenticatedAuthenticationProvider();
provider.setPreAuthenticatedUserDetailsService(
new UserDetailsByNameServiceWrapper<PreAuthenticatedAuthenticationToken>(users));
provider.setThrowExceptionWhenTokenRejected(true);
provider.afterPropertiesSet();
var repository = new RequestAttributeSecurityContextRepository();
var filter = new RequestHeaderAuthenticationFilter();
filter.setPrincipalRequestHeader("SM_USER");
filter.setExceptionIfHeaderMissing(false);
filter.setAuthenticationManager(new ProviderManager(provider));
filter.setSecurityContextRepository(repository);
filter.setCheckForPrincipalChanges(true);
filter.setAuthenticationFailureHandler((request, response, failure) -> response.sendError(403));
filter.afterPropertiesSet();
return http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(c -> c.securityContextRepository(repository))
.exceptionHandling(e -> e.authenticationEntryPoint(new Http403ForbiddenEntryPoint()))
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.addFilterAt(filter, AbstractPreAuthenticatedProcessingFilter.class).build();
}
}缺头默认会抛 PreAuthenticatedCredentialsNotFoundException,本例显式 false,让未提取到身份的请求继续进入授权,由 403 entry point 拒绝;这不等于允许匿名。过滤器的 continueFilterChainOnUnsuccessfulAuthentication 默认 true,checkForPrincipalChanges 默认 false、身份改变时默认失效旧 session;本例用 request attribute repository/STATELESS 避免把昨天代理头建立的会话当今天的认证。若选择有状态方案,必须处理代理身份变化、头缺失、账号停用与旧会话撤销,不能只开 checkForPrincipalChanges 就宣称实时撤权。基类默认与成功保存。
Provider 并不验证上游证据,只检查 token 的非空身份/凭据、加载 UserDetails、校验账号状态,再组合权限。默认 grantedAuthoritySupplier 为空,所以 header 认证不会自动变成 PASSWORD/X509 因子。
固定原码:PreAuthenticatedAuthenticationProvider.java,连续 86–119 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
public @Nullable Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!supports(authentication.getClass())) {
return null;
}
logger.debug(LogMessage.format("PreAuthenticated authentication request: %s", authentication));
if (authentication.getPrincipal() == null) {
logger.debug("No pre-authenticated principal found in request.");
if (this.throwExceptionWhenTokenRejected) {
throw new BadCredentialsException("No pre-authenticated principal found in request.");
}
return null;
}
if (authentication.getCredentials() == null) {
logger.debug("No pre-authenticated credentials found in request.");
if (this.throwExceptionWhenTokenRejected) {
throw new BadCredentialsException("No pre-authenticated credentials found in request.");
}
return null;
}
UserDetails userDetails = this.preAuthenticatedUserDetailsService
.loadUserDetails((PreAuthenticatedAuthenticationToken) authentication);
this.userDetailsChecker.check(userDetails);
Collection<GrantedAuthority> authorities = new LinkedHashSet<>(userDetails.getAuthorities());
authorities.addAll(this.grantedAuthoritySupplier.get());
PreAuthenticatedAuthenticationToken result = new PreAuthenticatedAuthenticationToken(userDetails,
authentication.getCredentials(), authorities);
result.setDetails(authentication.getDetails());
return result;
}
/**
* Indicate that this provider only supports PreAuthenticatedAuthenticationToken
* (sub)classes.同一框架还支持两种入口。以下完整工厂方法供替换 PreAuthConfig 中 filter 的创建部分,继续使用同一 manager/repository/403 处理;不是要把三种 filter 同时装入链。RequestAttributeAuthenticationFilter 默认属性 REMOTE_USER,属性必须由受信的容器适配层设置,不能让应用把未经认证 header 复制进去。J2eePreAuthenticatedProcessingFilter 从容器 request.getUserPrincipal().getName() 取身份;需先配置容器认证,而非在 Controller 中随手 set 一个 principal。
package enterprise;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.preauth.RequestAttributeAuthenticationFilter;
import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter;
import org.springframework.security.web.context.SecurityContextRepository;
public final class PreAuthSources {
public static RequestAttributeAuthenticationFilter attributeSource(
AuthenticationManager manager, SecurityContextRepository repository) {
var filter = new RequestAttributeAuthenticationFilter();
filter.setPrincipalEnvironmentVariable("REMOTE_USER");
filter.setExceptionIfVariableMissing(false);
filter.setAuthenticationManager(manager);
filter.setSecurityContextRepository(repository);
return filter;
}
public static J2eePreAuthenticatedProcessingFilter containerSource(
AuthenticationManager manager, SecurityContextRepository repository) {
var filter = new J2eePreAuthenticatedProcessingFilter();
filter.setAuthenticationManager(manager);
filter.setSecurityContextRepository(repository);
return filter;
}
}若权限来自容器角色,用 J2eeBasedPreAuthenticatedWebAuthenticationDetailsSource#setMappableRolesRetriever 指定有限候选角色,对每个角色调用 isUserInRole;默认 SimpleAttributes2GrantedAuthoritiesMapper 只加 ROLE_(已有前缀不重复),默认不转大写,与 LDAP populator 不同。再把 details 交给 PreAuthenticatedGrantedAuthoritiesUserDetailsService,而非 UserDetailsByNameServiceWrapper。也可继续用本例本地用户服务读取业务角色,此时容器 role 不自动成为权限。web.xml 的 WebXmlMappableAttributesRetriever 与硬编码 SimpleMappableAttributesRetriever 都是候选列表来源,不是扫描所有请求属性自动授予权限。容器 details 实现、属性入口。
预期恢复步骤:先用授权入口合法访问;再从被禁止直连的网络路径发送 SM_USER: alice,应在入口被拒,不能仅测试 Spring 的 403;代理漏头时本链 403,修复代理重写后恢复;换头为不存在账号仍拒。若检查发现直连伪造成功,先封闭入口并撤销可能建立的会话,再恢复代理,不能只重置 alice 密码。容量/撤销选择见后文,header 身份本身没有能在本地独立验证的有效期。
JAAS:LoginModule、Subject 与业务权限的桥接
JAAS 是 JDK 的 LoginContext/Subject/LoginModule 体系;包名仍是 javax.security.auth,不因 Servlet 改成 Jakarta 就全部改名。Spring 已从表单收集用户名密码,JaasNameCallbackHandler/JaasPasswordCallbackHandler 把它们交给 LoginModule;login() 成功后,AuthorityGranter 把 Subject 中受信 Principal 映射为业务 authorities。JAAS principal 不天然等于本地角色,Spring 没有通用生产 AuthorityGranter。固定 JAAS 文档。
下面使用 JDK 的 Krb5LoginModule 作为需要测试 KDC 的 JAAS 适配配置,不是后面接受浏览器服务票的 SPNEGO。共同依赖已含 JAAS Provider,不额外添加猜测的 JAAS starter;运行环境须有可信 krb5.conf、可达测试 KDC、realm 账号与时间同步。使用 java -Djava.security.krb5.conf=/path/to/test-krb5.conf -jar ... 明确本进程配置,不覆盖系统全局文件。只有 allowlist 中的完整 realm principal 得到 FINANCE_READ;其他成功 Subject 不会自动成为财务用户。
package enterprise;
import java.util.Map;
import java.util.Set;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.security.auth.login.AppConfigurationEntry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.jaas.AuthorityGranter;
import org.springframework.security.authentication.jaas.DefaultJaasAuthenticationProvider;
import org.springframework.security.authentication.jaas.memory.InMemoryConfiguration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.jaasapi.JaasApiIntegrationFilter;
@Configuration(proxyBeanMethods = false)
public class JaasConfig {
@Bean
DefaultJaasAuthenticationProvider jaasProvider() {
var entry = new AppConfigurationEntry("com.sun.security.auth.module.Krb5LoginModule",
AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
Map.of("useTicketCache", "false", "doNotPrompt", "false",
"storeKey", "false", "isInitiator", "true", "debug", "false"));
var configuration = new InMemoryConfiguration(Map.of(
"ENTERPRISE", new AppConfigurationEntry[] { entry }));
AuthorityGranter granter = principal -> principal instanceof KerberosPrincipal
&& principal.getName().equals("alice@EXAMPLE.TEST")
? Set.of("ROLE_FINANCE_READ") : Set.of();
var provider = new DefaultJaasAuthenticationProvider();
provider.setConfiguration(configuration);
provider.setLoginContextName("ENTERPRISE");
provider.setAuthorityGranters(new AuthorityGranter[] { granter });
return provider;
}
@Bean
SecurityFilterChain jaasChain(HttpSecurity http, DefaultJaasAuthenticationProvider provider) throws Exception {
return http.authenticationManager(new ProviderManager(provider))
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.addFilter(new JaasApiIntegrationFilter()).build();
}
}DefaultJaasAuthenticationProvider 使用注入的 JAAS Configuration 创建 LoginContext,避免依赖默认 ConfigFile;InMemoryConfiguration 是“登录模块配置在内存”,不是“用户密码内存校验”。默认 loginContextName 是 SPRINGSECURITY,本例显式 ENTERPRISE;未显式指定 callbackHandlers 时初始化 Name/Password 两个 handler。REQUIRED 表示该模块必须成功,多个模块的 REQUIRED/REQUISITE/SUFFICIENT 组合需按 JAAS 规则审读,不能把“某模块成功”当整个 LoginContext 成功。源码连续展示登录与失败异常映射:
固定原码:AbstractJaasAuthenticationProvider.java,连续 166–198 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
public @Nullable Authentication authenticate(Authentication auth) throws AuthenticationException {
if (!(auth instanceof UsernamePasswordAuthenticationToken request)) {
return null;
}
Set<GrantedAuthority> authorities;
try {
// Create the LoginContext object, and pass our InternalCallbackHandler
LoginContext loginContext = createLoginContext(new InternalCallbackHandler(auth));
// Attempt to login the user, the LoginContext will call our
// InternalCallbackHandler at this point.
loginContext.login();
// Get the subject principals and pass them to each of the AuthorityGranters
Set<Principal> principals = loginContext.getSubject().getPrincipals();
// Create a set to hold the authorities, and add any that have already been
// applied.
authorities = getAuthorities(principals);
// Convert the authorities set back to an array and apply it to the token.
Object principal = request.getPrincipal();
Assert.notNull(principal, "The principal cannot be null");
JaasAuthenticationToken result = new JaasAuthenticationToken(principal, request.getCredentials(),
new ArrayList<>(authorities), loginContext);
// Publish the success event
publishSuccessEvent(result);
// we're done, return the token.
return result;
}
catch (LoginException ex) {
AuthenticationException resolvedException = this.loginExceptionResolver.resolveException(ex);
publishFailureEvent(request, resolvedException);
throw resolvedException;
}7.0.8 会在映射结果中追加 FACTOR_PASSWORD,但这个标签不替 LoginModule 做账号禁用检查;AbstractJaasAuthenticationProvider 没有通用的 AccountStatusUserDetailsChecker,停用/离职控制要由模块及本地映射策略承担。权限构造。
历史文件配置是另一条路线:JaasAuthenticationProvider 的 loginConfig 指向 classpath login.conf,其中 ENTERPRISE { com.sun.security.auth.module.Krb5LoginModule required useTicketCache=false doNotPrompt=false; };,loginContextName 同样 ENTERPRISE,AuthorityGranter/回调仍须装配。该 Provider 依赖默认 JAAS ConfigFile 并尝试更新配置,不能与上面的注入式 Provider 混用后声称各应用完全隔离;新配置优先选择 DefaultJaasAuthenticationProvider。两个 Provider 的区别。
JaasApiIntegrationFilter 只在认证携带可用 JAAS Subject 时用 Subject.doAs 执行后续链,默认不会凭空构造一个可信 Subject。业务若依赖 JAAS Subject,需在该调用边界验证,不能把 SecurityContext 的用户当任意线程里的 Subject;异步工作还要显式设计传播。会话销毁事件到达 AbstractJaasAuthenticationProvider 后才尝试 LoginContext.logout,Servlet 工程需正确发布 HttpSession 事件(例如 HttpSessionEventPublisher),否则不能假设登出回调一定发生。JDK21 的 Subject/AccessController 用法不能未经核实直接推广到之后移除相关能力的 JDK。Subject 执行、会话销毁处理。
预期失败样本为拼错 ENTERPRISE、模块类缺失、UnsupportedCallbackException、KDC/密码导致 LoginException、或认证成功却没有允许的 Principal。逐层检查配置名/模块路径/回调/模块失败/角色映射,修复后新建 LoginContext 重新认证,并同时复验财务授权与 logout;不能通过给所有 Principal 返回 ROLE_FINANCE_READ 修复授权失败。扩容时每次 JAAS 登录对 KDC/其他模块后端的调用计入认证预算;缓存结果必须同时定义账号撤销和 Subject 资源释放时机。
CAS:票据绑定 service,ST 与 PT 的生命周期不同
CAS server 保持单点登录状态,应用是登记的 service,浏览器带回不透明 Service Ticket。应用必须通过受信 HTTPS TicketValidator 向 CAS 验证 ticket 与 service URL 的组合,再从本地用户源加载权限;仅看 ticket=ST-... 前缀不构成认证。SAML 的本地验签与 CAS 的服务器回查是不同故障模型。
共同外壳增加 org.springframework.security:spring-security-cas,其传递依赖提供 Apereo CAS client。下面使用 ST validator,不接受代理票;CAS 服务与应用回调的证书链均已配置为可信是前提。ServiceProperties.service 必须是 CAS 登记且实际由 filter 处理的外部回调 URL。EnterpriseAccounts 提供本地账号/角色;其中密码不用于 CAS 验票。
package enterprise;
import org.apereo.cas.client.validation.Cas20ServiceTicketValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
@Configuration(proxyBeanMethods = false)
public class CasConfig {
@Bean
ServiceProperties casService() {
var service = new ServiceProperties();
service.setService("https://reports.example.test/login/cas");
service.setSendRenew(false);
service.setAuthenticateAllArtifacts(false);
return service;
}
@Bean
CasAuthenticationProvider casProvider(ServiceProperties service, UserDetailsService users) {
var provider = new CasAuthenticationProvider();
provider.setServiceProperties(service);
provider.setTicketValidator(new Cas20ServiceTicketValidator("https://cas.example.test/cas"));
provider.setUserDetailsService(users);
provider.setKey("u31-cas-provider-identity");
return provider;
}
@Bean
SecurityFilterChain casChain(HttpSecurity http, ServiceProperties service,
CasAuthenticationProvider provider) throws Exception {
var entry = new CasAuthenticationEntryPoint();
entry.setLoginUrl("https://cas.example.test/cas/login");
entry.setServiceProperties(service);
entry.afterPropertiesSet();
var filter = new CasAuthenticationFilter();
filter.setServiceProperties(service);
filter.setAuthenticationManager(new ProviderManager(provider));
filter.setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy());
filter.afterPropertiesSet();
return http.exceptionHandling(e -> e.authenticationEntryPoint(entry))
.authorizeHttpRequests(a -> a.requestMatchers("/login/cas").permitAll()
.requestMatchers("/statements/**").hasRole("FINANCE_READ")
.anyRequest().authenticated())
.addFilterAt(filter, CasAuthenticationFilter.class).build();
}
}sendRenew=false 是默认,允许复用 CAS SSO;敏感 service 可要求 renew=true 强制新认证,但这不代替 MFA。authenticateAllArtifacts 默认 false,只在回调处理 ST;不要为“任何 API 都方便带 ticket”就打开。provider key 用来识别由本 Provider 建立的 token,不是 CAS 服务端密码,也不是跨系统签名密钥。ServiceProperties、当前 filter。
7.0.8 用 CasServiceTicketAuthenticationToken/CasAssertionAuthenticationToken 等类型,不能照旧文档用特定 principal 字符串伪造 UsernamePasswordAuthenticationToken。authenticateNow 的关键原码如下:先远程验证、加载账号和检查状态,再映射权限并加 FACTOR_CAS;失败抛 BadCredentialsException。
固定原码:CasAuthenticationProvider.java,连续 142–174 行。SHA-256 为整文件摘要,代码未省略或教学改写。
private CasAuthenticationToken authenticateNow(final Authentication authentication) throws AuthenticationException {
try {
Object credentials = authentication.getCredentials();
if (credentials == null) {
throw new BadCredentialsException("Authentication.getCredentials() cannot be null");
}
Assertion assertion = this.ticketValidator.validate(credentials.toString(), getServiceUrl(authentication));
UserDetails userDetails = loadUserByAssertion(assertion);
this.userDetailsChecker.check(userDetails);
Collection<GrantedAuthority> authorities = new ArrayList<>(
this.authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));
authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY));
return new CasAuthenticationToken(this.key, userDetails, credentials, authorities, userDetails, assertion);
}
catch (TicketValidationException ex) {
throw new BadCredentialsException(ex.getMessage(), ex);
}
}
/**
* Gets the serviceUrl. If the {@link Authentication#getDetails()} is an instance of
* {@link ServiceAuthenticationDetails}, then
* {@link ServiceAuthenticationDetails#getServiceUrl()} is used. Otherwise, the
* {@link ServiceProperties#getService()} is used.
* @param authentication
* @return
*/
@NullUnmarked
private @Nullable String getServiceUrl(Authentication authentication) {
String serviceUrl;
if (authentication.getDetails() instanceof ServiceAuthenticationDetails) {
return ((ServiceAuthenticationDetails) authentication.getDetails()).getServiceUrl();
}预期输入 ticket=ST-issued-for-other-service 即使确由同一 CAS 签发,也应因 service 不匹配而失败;已验证消费的 ST 再送一次也应拒绝。反代若把外部 HTTPS 回调错误拼成内部 HTTP URL,会出现同类 service 失败。恢复顺序是核对登记 URL、应用 service、代理基址、TLS 信任,再重新访问 CAS 获取新票,不重试旧票、不关闭 hostname 校验。CAS 不可达只应影响需要回查的新认证;已有本地会话是否继续访问由本地策略决定,不能把不可达解释成账号通过。
CAS SLO、代理票与缓存的具体责任
普通 POST /logout 只清本地会话。企业退出需要另外接入 Apereo SingleSignOutFilter 和 SingleSignOutHttpSessionListener:前者在 CAS 回调记录 ticket→HttpSession 映射,接收 CAS logoutRequest 后定位并 invalidate;后者在 session 销毁时移除映射。filter 应在 Security CAS filter 之前接收回调,listener 在容器注册;只把类 new 出来但不初始化/注册不会生效。发起端先本地退出再跳转 CAS logout URL,并显式评估外部 SLO 请求的受信入口、防伪造策略与字符编码。默认静态内存 session 映射不天然支持多个应用实例;需要路由亲和或共享失效方案,CAS logout 成功页不证明每个节点的会话已失效。SLO 配置与映射责任。
应用 A 代表用户调用 B 时才需要代理能力:将 validator 换成 Cas20ProxyTicketValidator;配置 proxyCallbackUrl=https://a.example.test/login/cas/proxyreceptor、共享 ProxyGrantingTicketStorage;CasAuthenticationFilter 配同一 storage 和匹配的 proxyReceptorUrl=/login/cas/proxyreceptor。CAS 回调交付 PGT 与 IOU,A 用 PGT 为目标 service B 换取 PT,B 仍须验证 PT 的目标 service 和允许的代理链。storage 清理过期 PGT 是应用职责;不应照样例把 acceptAnyProxy=true 当生产默认,需配置允许的代理链并拒绝未知代理。HTTPS 的双向回调信任、代理链和目标 URL allowlist 都要明确,不把目标 service 任意交给客户端。PGT/PT 装配。
无状态 PT 复用依赖的是 CasAuthenticationProvider 的 StatelessTicketCache;默认 NullStatelessTicketCache 不保留结果。配置缓存后,第二次命中可不回查 CAS,这不代表同一个 PT 能向 CAS 无限次验证。缓存把单次验票转换成一段本地可复用身份,要限制 TTL、容量、目标服务隔离和撤销/代理变更时的清空;不得从备份恢复旧 cache。若 TTL=5 分钟,退出事件丢失时可能仍允许旧身份近 5 分钟,必须写进对业务的撤销承诺。ST、PT、PGT、CAS SSO cookie、本地 session 各自撤销,不能用一个“logout”字样掩盖状态分散。
Kerberos:KDC、服务主体、keytab 与 SPNEGO
Kerberos 客户端先通过 KDC 取得 TGT,再为 HTTP/reports.example.test@EXAMPLE.TEST 申请服务票;浏览器用 SPNEGO 在 Authorization: Negotiate 中传入服务票,服务端使用该服务主体的 keytab/GSS 验证后得到用户 principal,再查本地账号/权限。TGT、服务票、keytab 和本地会话是四类状态;keytab 是长期服务密钥材料,不能当作每个用户的密码表。
7.0.8 已将 Kerberos 并入主项目,依赖是 org.springframework.security:spring-security-kerberos-core 与 spring-security-kerberos-web;下文的客户端/LDAP 适配另加 spring-security-kerberos-client(需要 LDAP 时还加 spring-security-ldap 和 data-ldap starter),KDC 测试工具才使用 test 模块。旧 groupId org.springframework.security.kerberos 与旧独立版本不能套到本例。运行前提包括测试 realm/KDC、正确 DNS/FQDN/SPN、可读且 kvno/enctype 匹配的 keytab、同步时钟和支持 Negotiate 的客户端。本页不创建 KDC 或 keytab。固定坐标迁移、使用与角色。
下面选无状态 SPNEGO,每次请求有票据,沿用 EnterpriseAccounts 并设 enterprise.subject=alice@EXAMPLE.TEST。未认证入口发 401 Negotiate,不自动回退到另一套弱认证;真正需要表单回退时才单独装配 KerberosAuthenticationProvider 或 LDAP Provider。配置使用 lambda DSL,固定源码 docs/examples 中遗留 .and() 的旧样例不能直接编译为 7.0.8。
package enterprise;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.kerberos.authentication.KerberosServiceAuthenticationProvider;
import org.springframework.security.kerberos.authentication.sun.SunJaasKerberosTicketValidator;
import org.springframework.security.kerberos.web.authentication.SpnegoAuthenticationProcessingFilter;
import org.springframework.security.kerberos.web.authentication.SpnegoEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
@Configuration(proxyBeanMethods = false)
public class KerberosConfig {
@Bean
SunJaasKerberosTicketValidator ticketValidator(@Value("${enterprise.keytab}") String keytab) {
var validator = new SunJaasKerberosTicketValidator();
validator.setServicePrincipal("HTTP/reports.example.test@EXAMPLE.TEST");
validator.setKeyTabLocation(new FileSystemResource(keytab));
validator.setDebug(false);
validator.setMultiTier(false);
validator.setRefreshKrb5Config(false);
return validator;
}
@Bean
KerberosServiceAuthenticationProvider kerberosProvider(
SunJaasKerberosTicketValidator validator, UserDetailsService users) {
var provider = new KerberosServiceAuthenticationProvider();
provider.setTicketValidator(validator);
provider.setUserDetailsService(users);
return provider;
}
@Bean
SecurityFilterChain kerberosChain(HttpSecurity http,
KerberosServiceAuthenticationProvider provider) throws Exception {
var repository = new RequestAttributeSecurityContextRepository();
var filter = new SpnegoAuthenticationProcessingFilter();
filter.setAuthenticationManager(new ProviderManager(provider));
filter.setSecurityContextRepository(repository);
filter.setFailureHandler((request, response, failure) -> response.sendError(401));
filter.afterPropertiesSet();
return http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(c -> c.securityContextRepository(repository))
.exceptionHandling(e -> e.authenticationEntryPoint(new SpnegoEntryPoint()))
.authorizeHttpRequests(a -> a.requestMatchers("/statements/**")
.hasRole("FINANCE_READ").anyRequest().authenticated())
.addFilterBefore(filter, BasicAuthenticationFilter.class).build();
}
}SunJaasKerberosTicketValidator 初始化时用指定 principal/keytab 做 JAAS login,debug/multiTier/refreshKrb5Config 默认 false,本例显式相同;keytab 路径由属性 enterprise.keytab 指定到隔离文件,不能仅在示例写一个不存在路径再声称启动可用。启动初始化。
服务 Provider 先校验票据,再加载用户/检查账号状态,再构造结果;不会因为收到一个 Negotiate 头就相信其中宣称的用户名。
固定原码:KerberosServiceAuthenticationProvider.java,连续 64–89 行。SHA-256 为整文件摘要,代码未省略或教学改写。
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
KerberosServiceRequestToken auth = (KerberosServiceRequestToken) authentication;
byte[] token = auth.getToken();
LOG.debug("Try to validate Kerberos Token");
KerberosTicketValidation ticketValidation = this.ticketValidator.validateTicket(token);
LOG.debug("Successfully validated " + ticketValidation.username());
UserDetails userDetails = this.userDetailsService.loadUserByUsername(ticketValidation.username());
this.userDetailsChecker.check(userDetails);
additionalAuthenticationChecks(userDetails, auth);
KerberosServiceRequestToken responseAuth = new KerberosServiceRequestToken(userDetails, ticketValidation,
userDetails.getAuthorities(), token);
responseAuth.setDetails(authentication.getDetails());
return responseAuth;
}
@Override
public boolean supports(Class<? extends Object> auth) {
return KerberosServiceRequestToken.class.isAssignableFrom(auth);
}
@Override
public void afterPropertiesSet() throws Exception {
Assert.notNull(this.ticketValidator, "ticketValidator must be specified");
Assert.notNull(this.userDetailsService, "userDetailsService must be specified");
}不能声称本 Provider 自动追加 FACTOR_PASSWORD/WEBAUTHN;它采用用户源权限,Kerberos 与内建 MFA 因子整合需要另做可信映射与新鲜度设计。SPNEGO filter 默认 repository 是 RequestAttribute,skipIfAlreadyAuthenticated=true、sessionStrategy 为 NullAuthenticatedSessionStrategy;若要建立长期会话,须同时配置 HttpSession repository 与实际 SessionAuthenticationStrategy,并评估旧 session 跳过再验票的撤销窗口,不能只把 STATELESS 删掉。过滤器默认。
预期失败输入分别是请求无 Authorization、Negotiate TlRMTVNTUA... 的 NTLM 回退、Base64 有效但服务票无效、Negotiate !!! 非法 Base64。无头/排除的 NTLM 会继续到未认证入口;票据验证 AuthenticationException 的原默认处理清 Context 并返回 500,本例 failureHandler 改为 401。非法 Base64 解码在这个 catch 之前,仍不能承诺所有坏头都被该 handler 变成 401;需要边界错误映射时另加严格校验,不应把 RuntimeException 统统当“密码错”。源代码还在 debug/warn 中记录完整 Negotiate header,因此不能建议在真实票据环境直接开启这两级原始日志;应评估日志过滤/修复,保留不含票据的原因类别。精确失败分支。
KDC 管理员检查 SPN 唯一性、kvno/enctype 与密钥轮换,客户端管理员检查 DNS/受信站点/票据缓存,应用管理员检查 realm 配置、keytab 权限、principal 映射与时钟。修复后销弃测试客户端旧票、重新获取服务票,再重新 Negotiate;不要把公网 IP 当服务 FQDN、不要将 keytab 发给客户端、不要用关闭通道绑定/TLS 校验的方式掩盖 SPN 问题。可执行的隔离验证路径为 kinit alice@EXAMPLE.TEST → kvno HTTP/reports.example.test@EXAMPLE.TEST → 使用支持 GSS 的 curl --negotiate -u : https://reports.example.test/statements/1;本页这些步骤为待具备 KDC 后的预期,不是运行记录。
用户密码、客户端与 Kerberized LDAP
KerberosAuthenticationProvider 接收 UsernamePasswordAuthenticationToken,交给 SunJaasKerberosClient 用用户密码向 KDC 登录,再查 UserDetailsService;它与接受服务票的 KerberosServiceAuthenticationProvider 不是同一 Provider。选择密码回退时,用下列工厂装配并注册到相应表单链;这里 users 必须执行停用检查,不能假定两个 Provider 行为相同。密码 Provider。
package enterprise;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.kerberos.authentication.KerberosAuthenticationProvider;
import org.springframework.security.kerberos.authentication.sun.SunJaasKerberosClient;
import org.springframework.security.kerberos.client.KerberosRestTemplate;
public final class KerberosClients {
public static KerberosAuthenticationProvider passwordProvider(UserDetailsService users) {
var client = new SunJaasKerberosClient();
client.setDebug(false);
var provider = new KerberosAuthenticationProvider();
provider.setKerberosClient(client);
provider.setUserDetailsService(users);
return provider;
}
public static KerberosRestTemplate ticketCacheClient() {
return new KerberosRestTemplate();
}
public static KerberosRestTemplate keytabClient(String keytab, String principal) {
return new KerberosRestTemplate(keytab, principal);
}
}无参 KerberosRestTemplate 使用票据缓存,keytab/principal 构造器使用指定客户端身份;调用 getForObject 是阻塞 HTTP,不把它塞进 Reactor 事件循环。Krb5LoginModule loginOptions 与自定义 HttpClient 可定制,但连接/读取超时、目标 allowlist、凭据生命周期要完整设置;它不会自动将服务收到的每个用户票据变成用户委托凭据。客户端实现。
要在服务票验证后查询 AD/LDAP 属性,可以用 KerberosLdapContextSource,其 SunJaasKrb5LoginConfig 配 servicePrincipal/keyTabLocation、setIsInitiator(true),FilterBasedLdapUserSearch 配相对 search base 与参数过滤器,LdapUserDetailsService 配前文的显式组 populator。这里是服务身份以 Kerberos 绑定目录读取用户资料;用户委托是另一个更强的信任配置,不能因为访问了 LDAP 就宣称已做 delegation。目录不可达时不能凭票据给所有人默认财务角色;修目录访问/服务身份权限后重试,已建立的会话仍按明示刷新策略处理。完整客户端 LDAP 配置依据。
历史集成的迁移归属
Run-As 的临时权限、内部 key、旧拦截器调用链与 finally 恢复由资源授权正文的 Run-As 章节唯一维护。7.0.8 将旧 Access API 移至 spring-security-access;它不是企业 SSO,也不会因添加 RunAsManager Bean 就自动接入现代 @EnableMethodSecurity。迁移到本页任一种企业身份源时,应保留原调用者、委托权限与异常恢复契约,不把外部认证成功解释成内部自动提权。固定迁移依据。
条件变化一:目录认证容量与撤权时限
以下是可计算的合成场景,不是压测数据。企业 60,000 名员工,15 分钟开工窗口有 18,000 次新登录,均值 18,000 / 900 = 20 次/秒。先假设每次需要用户 search、用户 bind、组 search 三个目录操作,则约 60 次目录操作/秒;若 DN 模式省掉用户 search,是约 40 次/秒,但代价是必须有稳定 OU 规则。多 DN pattern 失败尝试、嵌套组、自定义 mapper 额外读取、客户端重试都会增加这个数,不能从“三步”假设推导实际固定三次网络往返。
若规划输入为一次完整 LDAP 认证平均占连接/执行槽 100 ms,Little 定律给平均在途 20/s × 0.1s = 2;按 300 ms 的故障预算估算需 6 个在途槽,再预留 4 倍突发量得到 24 个受限认证槽。这只是初始准入上限候选,不是“池大小24必定足够”;必须用真实分位延迟、每段连接复用、锁定策略和目录限额修正。排队满了应快速拒绝/有限重试并给登录服务降载,不能无限扩线程将目录故障扩大成 JVM 资源耗尽。
比较两个都合理的部署方案:
| 方案 | 容量收益与成本 | 撤销承诺与故障恢复 |
|---|---|---|
| 登录时 LDAP 验证,之后使用共享 session 和权限快照 | 目录主要承担新登录约 20/s;业务 4,000 请求/s 无须全部回目录。需会话存储、用户→session 索引和事件传播 | 撤组/离职事件触发 session 失效;事件丢失时仍需周期重查或短最大寿命,否则可能持续至会话过期。目录故障时可保留已授权低风险读取,高风险操作按撤权策略拒绝;恢复后重建映射版本并失效不一致会话 |
| 每个请求通过可信 pre-auth,动态检查本地账号/权限版本 | 上游承担认证,应用受 4,000/s 的用户/权限读取影响;假设读取20 ms则需约80个平均在途槽,数据库和入口都要限额 | 若不用缓存,撤销取决于上游+本地版本传播,可更快但依赖更强;若缓存30s则必须承诺额外最多约30s旧权限,故障时不能把查不到版本当“未开启限制”。恢复后先追平撤销版本再放流 |
第二方案若有 12,000 个均匀活跃身份、每个权限快照缓存 30 秒,粗略刷新量为 12,000 / 30 = 400 次/秒,前提是每身份合并并发刷新且访问均匀。整点同时失效仍可产生 12,000 次瞬时刷新,应用需要抖动、单飞与有界队列;不能拿平均值掩盖缓存击穿。若业务要求“离职5秒内失效”,30秒缓存方案不满足要求,应缩短TTL并预算读取成本,或采用带版本事件和可靠兜底的撤销通道。
恢复推演:10:00 撤销 alice 的财务组,事件总线中断 60 秒。如果方案一仅依赖事件而 session 还能活30分钟,10:00:05 的资金操作仍可能放行,违反不变量;修复是为高风险路径同步查版本或设置独立更短的授权租约,事件恢复后追补撤销,再复验旧会话与新登录。方案二若权限服务不可达,资金操作应拒绝且不扩大权限;低风险读是否容忍有限陈旧须另有业务批准的窗口,不能自动 fail-open。两方案都要检查“认证成功但业务角色已撤”的反例;账号禁用、LDAP 组撤销、IdP 退出、本地 session 失效是不同事件。
验证方法是记录合成用户在撤组前后请求的服务端版本、决策与时间,故障注入目录超时/事件丢失/权限库不可达,恢复后验证旧权限不复活。不要只用 LDAP 新 bind 失败证明全部应用会话都已撤销。该分析依赖前文 LDAP 与 pre-auth 的真实装配边界,以及上下文保存/失效机制,不宣称实现了共享 session 产品部署。
条件变化二:多节点 OTT、Passkey 与消费恢复
假设 4 个应用节点,OTT 生成速率 120/s,TTL=300s。在完全未消费的上界情形,活跃记录约 120 × 300 = 36,000;规划每条索引/对象摊销暂估600字节,则约21.6MB,600/s突发同窗口则180,000条、108MB。字节估值不是框架实测,还未计 JVM/DB 页、二级索引、日志、复制、过期未清理和投递队列。5分钟TTL不保证数据库只保留5分钟记录:默认 hourly cleanup 会让已过期未消费行继续占空间,容量应另计清理周期的积累。
| 存储选择 | 一次性与可用性 | 恢复代价 |
|---|---|---|
| 每节点 InMemoryOneTimeTokenService,加路由亲和 | 单JVM的remove有明确竞争裁决;4个节点不是一个共享token库 | 重启或落到别的节点会失败;不能把同一token复制到4份内存再允许各自消费。节点丢失后重发新token,接受少量可用性损失 |
| 共享 JdbcOneTimeTokenService | 每条token主键,竞争者通过DELETE影响行数裁决;需要共享数据库可用、写提交成功且不被外部回滚 | 数据库故障时拒绝认证;消费已提交但响应丢失时重发。备份恢复必须防止已消费token复活,可选择清空认证临时表并重发,而不是恢复后继续接受快照中的旧token |
| 自定义共享原子消费服务 | 例如具备TTL与原子get-and-delete的存储,需自己实现OneTimeTokenService、故障语义与复制边界 | 若异步副本提升丢了消费删除,旧token可能重现;必须定义一致性等级/恢复代次,不能仅凭“用了Redis”声称一次性跨故障成立 |
共享JDBC按每次成功生成一次INSERT、成功消费一次SELECT+DELETE估计上限约360条语句/s,另加清理和索引维护;这不是吞吐能力声明。过期/错token查询、攻击流量、重试、账号读取与投递均要另算。请求入口限流是保护存储的不变量之一,不能等记录涨到100条才依靠内存清理防滥用。
Passkey 的持久状态与短期challenge分开规划。假设100,000个账号、每人2个credential、每条3KiB,原始凭据数据约 100,000 × 2 × 3 = 600,000 KiB ≈ 586 MiB;索引、attestation实际大小、备份及副本另算。默认Map仓库重启就丢注册,适合演示;共享JDBC可持久,但两个仓库和账号映射应一致。challenge如果仍存本地session,凭据进共享库也不能解决跨节点登录,必须再解决会话亲和/共享或自定义请求options存储。
预期并发恢复时序:A、B同时提交同一个OTT,A删除成功,B删除0行,只有A能继续;A在响应前断网,用户不能用该token重试,重发才是恢复。Passkey则是A先清challenge后签名失败,后续重放不能靠旧challenge恢复,重新取options;跨节点若两份challenge可独立消费,必须用相同user/session绑定和原子消费设计限制竞争。不要把签名计数写回成功、或单节点一次remove,推广为所有存储故障下的严格一次性。
撤设备时先删除credential并清用户/凭据缓存,再使已经认证的session失效;删除公钥只阻止未来使用该credential,不能撤掉已发的本地会话。数据库从昨天备份恢复会同时威胁OTT一次性和passkey撤销,恢复负责人需要撤销日志/代次或恢复后全面重新确认,而不是单纯“数据库启动成功”。验证输入应包含重复消费、两个节点交错、重启、删除后旧会话、备份恢复;本页只给推演,外部存储并发实验未执行。
条件变化三:计划轮换与信任失陷要不同恢复
计划轮换的目标是在新旧材料交替期间保留正确请求。假设 SAML 最长在途Response有效窗5分钟、允许时钟容差60秒、配置传播120秒,保守重叠预算为 300+60+120=480秒,即8分钟。方案A在受信metadata/配置中先加入新验签公钥,确认所有SP已加载,再切IdP签名key,窗口后移除旧key;方案B使用版本化离线metadata随发布原子替换,启动不依赖IdP,但发布延迟和回滚必须保留新旧可信快照。前者自动化复杂、刷新源依赖强,后者更新慢且需要发布协作;两者都不能从未经验证的新metadata自举信任。
若是旧IdP私钥泄漏,继续接受旧key8分钟会允许攻击者继续签断言,不能沿用可用性优先的计划窗口。此时应停止接受旧key、冻结高风险入口、通过受信带外途径确认新key/metadata,同时失效受影响本地session;没有可信新材料时宁可暂时无法新登录,也不能从浏览器上传或临时关闭验签“恢复”。SAML过去已验证的会话、CAS缓存、OTT/邮件恢复通道、passkey账号恢复权限都需要检查是否受到相同身份管理员失陷影响。
X.509轮换的是TLS客户端信任链,删除旧CA不保证已经建立的TLS连接/恢复票据/应用session立刻失效;要结合连接重建、已配置撤销检查和本地账号撤销。Kerberos计划轮keytab时须协调KDC kvno/enctype和未到期服务票;如果规划服务票寿命8小时、时钟容差5分钟、传播2分钟,则可能需评估8小时7分钟的正常兼容窗口,而非套用SAML的8分钟。keytab失陷时应按KDC能力撤旧密钥/票据并限制服务,不能保留旧密钥到所有攻击票自然到期。这些时间均为场景输入,真实企业必须以其策略和实现测量替换。
恢复验证要同时给“新材料成功”和“被撤旧材料失败”两份证据:新IdP签名的正确audience/recipient成功,旧泄漏key仍签得出XML却被拒;新客户端证书能建立新TLS,旧证书在对应撤销策略下失败;新keytab处理新服务票,旧kvno不被无意继续接受。监控使用registrationId/key版本/认证原因/传播时延等非秘密字段,不保留完整断言、token、Authorization或私钥。SLO失败、认证失败和session失效分别观测,恢复后不通过回滚信任集合复活已撤材料。SAML凭据职责、Kerberos keytab初始化。
验证口径与排障记录
本页15个完整 Java 配置/辅助类按 Boot4.0.9、Framework7.0.10、Security7.0.8 编写。2026-10-26,初版在 JDK BellSoft/Liberica21.0.11+11、Maven3.9.9、macOS27.0.1 arm64 下 clean compile 退出0;最终版包含五条替代链的财务角色规则与 SAML issuer/稳定NameID映射,已重新提取并于19:13:50 +08:00用 javac --release 21 和117份已解析依赖直接编译通过。它验证 imports、类型与调用签名,没有启动 Spring 上下文。最终编译证据保留输入、依赖口径、退出码及15个源文件SHA;隔离环境的 Maven 仓库解析问题与直接编译区分记录,不将两种检查混为一次运行。
完整类为 EnterpriseApplication、LdapConfig、LdapCompareAlternative、ActiveDirectoryConfig、SamlConfig、EnterpriseAccounts、X509Config、MfaConfig、PasskeyConfig、PreAuthConfig、PreAuthSources、JaasConfig、CasConfig、KerberosConfig、KerberosClients。它们是各章节独立替代配置,不是一起扫描的应用;SAML等外部信任材料与本地账号配置仍须按各章准备。题库解析已核对20题的答案、评分点及真实H2回查;13段连续源码和整文件SHA-256已逐段核验。
本页的源码段是固定commit连续摘录,标注整文件SHA-256;配置片段是按此API编写的教学配置,不是源码原文。企业端到端验证尚未执行:LDAP/AD ACL与TLS、SAML IdP/metadata/SLO、X509客户端握手、SMTP投递、真实认证器passkey、CAS ST/PT/PGT/SLO、JAAS/Kerberos KDC均为外部前提。没有用mock、静态已认证Context、Java编译或主任务响应式HTTP通过来替代这些结论。
开展隔离复验时,每种机制保留输入、时间/时区、版本与依赖树、选择的链/Provider、成功或失败层、无秘密的结果及修复后的对照。先测合法输入,再改变一个条件(错密码/错audience/无证书/缺因子/错origin/重放/伪造头/错service/旧kvno),恢复后同时重测合法输入和原反例。凭据层错误修复不等于权限恢复,恢复也不等于已撤凭据可以重用。每个章节的具体输入与责任划分是本页的验证步骤,不以“企业服务没搭”删掉机制解释。
闭卷验收
本页 20 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。