pom依赖

springboot版本采用2.3.2.RELEASE

1
spring-boot.version=2.3.2.RELEASE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-saml2-service-provider</artifactId>
<version>5.3.3.RELEASE</version>
</dependency>

配置

这里我采用了比较普遍的做法配置idp的medata.xml元数据,这是一个idp的凭证,包含了证书、算法、端点、IPD的信息等。相比于配置证书、端点等更容易理解、接受;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@Configuration
@EnableConfigurationProperties({SamlProperties.class})
public class SamlConfig extends WebSecurityConfigurerAdapter {

private static final Logger log = LoggerFactory.getLogger(SamlConfig.class);

@Autowired
private SamlProperties samlProperties;

@Autowired
SecurityAuthenticationSuccessHandler successHandler;
@Autowired
SecurityAuthenticationExceptionResolverHandler exceptionResolverHandler;

@Autowired
UserDetailsService userDetailsService;

/**
* 添加 SAML2 登录配置到 HttpSecurity
*
* @param http HttpSecurity 配置对象
* @throws Exception 配置异常
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//自定义的SAML认证器,包装了内置的OpenSamlAuthenticationProvider,主要用于替换内置认证只能返回username
SamlAuthenticationProvider samlAuthenticationProvider = new SamlAuthenticationProvider(
new OpenSamlAuthenticationProvider(),
userDetailsService
);
// 新建认证器, 两种方式。
// 1. 将 SAML2 认证提供者添加到 HttpSecurity,则不需要添加2对应的authenticationManager,保持使用统一的
// 2. .authenticationManager(new ProviderManager(samlAuthenticationProvider)),那么不需要http.authenticationProvider(samlAuthenticationProvider);
http.authenticationProvider(samlAuthenticationProvider);
http
.saml2Login()
//.authenticationManager(new ProviderManager(samlAuthenticationProvider))
.relyingPartyRegistrationRepository(relyingPartyRegistrationRepository())
.successHandler(successHandler) //自定义的SuccessHandler,可以和UsernamePasswordAuthenticationFilter设定的一样
.failureHandler(exceptionResolverHandler) //自定义异常解析器
.loginProcessingUrl(samlProperties.getLoginProcessingUrl()) //登录端点,SAMLResponse回调的端点地址
;
}

/**
* 配置 SAML2 Relying Party 注册信息。 主要配置IDP-SP的相关信息,如meatadata,sp-id,spring-security需要使用的Registration等
* 根据 useMetadata 配置决定从 metadata.xml 自动解析还是使用手动配置
*
* @return RelyingPartyRegistrationRepository
*/
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
List<RelyingPartyRegistration> registration;
//if (samlProperties.isUseMetadata()) {
registration = buildRegistrationFromMetadata(samlProperties);
//} else {
//registration = buildRegistrationFromProperties();
//}
return new InMemoryRelyingPartyRegistrationRepository(registration);
}

/**
* 从 IdP metadata.xml 中自动解析并构建 RelyingPartyRegistration
*/
private List<RelyingPartyRegistration> buildRegistrationFromMetadata(SamlProperties samlProperties) {
return samlProperties.getRegistrations().stream().map(
samlProperty -> buildRegistrationFromMetadata(samlProperty, samlProperties.getLoginProcessingUrl())
).collect(Collectors.toList());
}
private RelyingPartyRegistration buildRegistrationFromMetadata(SamlProperties.RegistrationSamlProperties samlProperties,String loginProcessingUrl) {
IdpMetadataParser parser = new IdpMetadataParser();
IdpMetadataParser.IdpMetadata metadata = parser.parse(samlProperties.getMetadataResource());

log.info("从 metadata.xml 解析成功: entityId={}, ssoUrl={}", metadata.getEntityId(), metadata.getSsoUrl());

Saml2X509Credential verificationCredential = new Saml2X509Credential(
metadata.getSigningCertificate(),
Saml2X509Credential.Saml2X509CredentialType.VERIFICATION
);

return RelyingPartyRegistration
.withRegistrationId(samlProperties.getRegistrationId())
.localEntityIdTemplate(samlProperties.getSpEntityId()) //默认值 {baseUrl}/saml2/service-provider-metadata/{registrationId}
//.assertionConsumerServiceUrlTemplate("{baseUrl}" + Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI)
.assertionConsumerServiceUrlTemplate("{baseUrl}"+loginProcessingUrl)
.providerDetails(providerDetails -> providerDetails
.entityId(metadata.getEntityId())
.webSsoUrl(metadata.getSsoUrl())
.binding(Saml2MessageBinding.POST)
)
.credentials(credentials -> credentials.add(verificationCredential))
.build();
}
}

认证器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class SamlAuthenticationProvider implements AuthenticationProvider {

private final OpenSamlAuthenticationProvider delegate;
private final UserDetailsService userDetailsService;

public SamlAuthenticationProvider(OpenSamlAuthenticationProvider delegate, UserDetailsService userDetailsService) {
this.delegate = delegate;
this.userDetailsService = userDetailsService;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 1. 调用默认的 OpenSamlAuthenticationProvider 进行认证
Saml2Authentication saml2Authentication = (Saml2Authentication) delegate.authenticate(authentication);

// 2. 从 SAML 认证中提取用户名
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) saml2Authentication.getPrincipal();
String username = principal.getName();

// 3. 从数据库加载完整的用户信息和权限
SecurityUser securityUser = (SecurityUser) userDetailsService.loadUserByUsername(username);
if (securityUser == null) {
throw new UsernameNotFoundException("未找到用户");
}
return new Saml2Authentication(
securityUser,
saml2Authentication.getSaml2Response(),
securityUser.getAuthorities()
);
}

@Override
public boolean supports(Class<?> authentication) {
return delegate.supports(authentication);
}
}

证书解析器,这个主要是用于解析metadata报文的工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
public class IdpMetadataParser {

private static final String SAML2_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol";
private static final String HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
private static final String HTTP_REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";

/**
* 从 metadata.xml 资源文件中解析 IdP 元数据信息
*
* @param metadataResource metadata.xml 资源
* @return 解析后的 IdP 元数据
*/
public IdpMetadata parse(Resource metadataResource) {
try {
InitializationService.initialize();

EntityDescriptor entityDescriptor = parseEntityDescriptor(metadataResource);
String entityId = entityDescriptor.getEntityID();

IDPSSODescriptor idpDescriptor = entityDescriptor.getIDPSSODescriptor(SAML2_PROTOCOL);
if (idpDescriptor == null) {
throw new IllegalStateException("metadata.xml 中未找到 IDPSSODescriptor");
}

String ssoUrl = extractSsoUrl(idpDescriptor);
X509Certificate certificate = extractCertificate(idpDescriptor);

return new IdpMetadata(entityId, ssoUrl, certificate);
} catch (Exception e) {
throw new IllegalStateException("解析 IdP metadata.xml 失败: " + metadataResource, e);
}
}

/**
* 解析 metadata.xml 为 EntityDescriptor
*/
private EntityDescriptor parseEntityDescriptor(Resource metadataResource) throws Exception {
BasicParserPool parserPool = new BasicParserPool();
parserPool.initialize();

try (InputStream inputStream = metadataResource.getInputStream()) {
Document document = parserPool.parse(inputStream);
Element element = document.getDocumentElement();

UnmarshallerFactory unmarshallerFactory = XMLObjectProviderRegistrySupport.getUnmarshallerFactory();
XMLObject xmlObject = unmarshallerFactory.getUnmarshaller(element).unmarshall(element);

if (xmlObject instanceof EntityDescriptor) {
return (EntityDescriptor) xmlObject;
}
throw new IllegalStateException("metadata.xml 根元素不是 EntityDescriptor");
}
}

/**
* 从 IDPSSODescriptor 中提取 SSO URL,优先使用 HTTP-POST 绑定,其次 HTTP-Redirect
*/
private String extractSsoUrl(IDPSSODescriptor idpDescriptor) {
List<SingleSignOnService> ssoServices = idpDescriptor.getSingleSignOnServices();

for (SingleSignOnService ssoService : ssoServices) {
if (HTTP_POST_BINDING.equals(ssoService.getBinding())) {
return ssoService.getLocation();
}
}
for (SingleSignOnService ssoService : ssoServices) {
if (HTTP_REDIRECT_BINDING.equals(ssoService.getBinding())) {
return ssoService.getLocation();
}
}
if (!ssoServices.isEmpty()) {
return ssoServices.get(0).getLocation();
}
throw new IllegalStateException("metadata.xml 中未找到 SingleSignOnService");
}

/**
* 从 IDPSSODescriptor 中提取用于签名验证的 X509 证书
*/
private X509Certificate extractCertificate(IDPSSODescriptor idpDescriptor) {
List<KeyDescriptor> keyDescriptors = idpDescriptor.getKeyDescriptors();

for (KeyDescriptor keyDescriptor : keyDescriptors) {
if (keyDescriptor.getUse() == null || keyDescriptor.getUse() == UsageType.SIGNING) {
KeyInfo keyInfo = keyDescriptor.getKeyInfo();
if (keyInfo != null) {
List<X509Data> x509DataList = keyInfo.getX509Datas();
for (X509Data x509Data : x509DataList) {
List<org.opensaml.xmlsec.signature.X509Certificate> certificates = x509Data.getX509Certificates();
for (org.opensaml.xmlsec.signature.X509Certificate cert : certificates) {
return parseCertificate(cert.getValue());
}
}
}
}
}
throw new IllegalStateException("metadata.xml 中未找到签名证书");
}

/**
* 将 Base64 编码的证书字符串解析为 X509Certificate
*/
private X509Certificate parseCertificate(String base64Certificate) {
try {
String cleaned = base64Certificate.replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(cleaned);
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
} catch (Exception e) {
throw new IllegalStateException("解析证书失败", e);
}
}

/**
* IdP 元数据解析结果,包含 entityId、ssoUrl 和签名证书
*/
public static class IdpMetadata {
private final String entityId;
private final String ssoUrl;
private final X509Certificate signingCertificate;

public IdpMetadata(String entityId, String ssoUrl, X509Certificate signingCertificate) {
this.entityId = entityId;
this.ssoUrl = ssoUrl;
this.signingCertificate = signingCertificate;
}

public String getEntityId() {
return entityId;
}

public String getSsoUrl() {
return ssoUrl;
}

public X509Certificate getSigningCertificate() {
return signingCertificate;
}
}
}

流程解析

配置文件中,主要涉及到以下几个部分

新建认证器

新创建一个认证器AuthenticationProvider,同时加入到全局AuthenticationProvider中,http.authenticationProvider(samlAuthenticationProvider);这里主要是把SAMLResponse中的返回解析成自己应用中的,其实也可以不配置,认证完成后返回就返回默认的principal和saml2Response也是可以的;

开启SAML登录

配置中http.saml2Login()即开启saml认证,这里主要需要配置默认的IDP和SP的属性对象RelyingPartyRegistrationRepository,里面主要封装SAML的IDP的相关信息,和sp的entityId等,我这里直接从meatadata元数据中获取对应的信息并加载到内存中保存,实际生产可以视情况添加其他的实现,如动态的上传并在使用的使用从数据库中读取元数据信息等。

1
2
3
4
5
private final String registrationId;
private final String assertionConsumerServiceUrlTemplate;
private final List<Saml2X509Credential> credentials;
private final String localEntityIdTemplate;
private final ProviderDetails providerDetails;

登录成功默认重定向到首页,我们可以重新设置successHandler,从而直接返回响应结果给前端(这里需要配合修改SAML回调地址的逻辑才行)

SAML默认有两个开放端点

  • sp登录发起地址/saml2/authenticate/{registrationId}
  • IDP Response请求的地址{baseUrl}/login/saml2/sso/{registrationId}

这两个地址可以根据需要修改,一般而言不需要修改也可以,自己弄懂规则就行。如果前后端分离项目,SAMLResponse回调的地址应该是前端的某个页面,再由前端携带Response到后端处理。

这两个地址是可以有规则的,其中{registrationId}就是根据自己的registrationId(security专用的,根据此来区分来源,可以去掉);
{baseUrl}则为项目发起的url。具体见org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration.Builder#assertionConsumerServiceUrlTemplate可以如下几个参数

baseUrl, registrationId, baseScheme, baseHost, and basePort.