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;
@Override public void configure(HttpSecurity http) throws Exception { SamlAuthenticationProvider samlAuthenticationProvider = new SamlAuthenticationProvider( new OpenSamlAuthenticationProvider(), userDetailsService ); http.authenticationProvider(samlAuthenticationProvider); http .saml2Login() .relyingPartyRegistrationRepository(relyingPartyRegistrationRepository()) .successHandler(successHandler) .failureHandler(exceptionResolverHandler) .loginProcessingUrl(samlProperties.getLoginProcessingUrl()) ; }
@Bean public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() { List<RelyingPartyRegistration> registration; registration = buildRegistrationFromMetadata(samlProperties); return new InMemoryRelyingPartyRegistrationRepository(registration); }
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()) .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 { Saml2Authentication saml2Authentication = (Saml2Authentication) delegate.authenticate(authentication);
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) saml2Authentication.getPrincipal(); String username = principal.getName();
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";
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); } }
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"); } }
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"); }
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 中未找到签名证书"); }
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); } }
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.