如何将 Spring-oAuth2 与 Spring-SAML 集成






4.86/5 (6投票s)
本文档介绍如何集成 Spring-Security-OAuth2 项目与 Spring-Security-SAML。
介绍
本文档介绍如何集成 Spring-Security-OAuth2 项目与 Spring-Security-SAML。
我假设读者熟悉 OAuth 及其组件,以及 SAML 及其组件。
动机
假设您希望您的系统支持 OAuth2。我建议使用 Spring-Security-OAuth 项目。当您使用 Spring 时,您可以享受到这个开源软件包的诸多好处:它被广泛使用,有响应式的支持(在论坛上),它是开源的,等等。这个软件包允许开发人员编写 OAuth 客户端、OAuth 资源服务器或 OAuth 授权服务器。
让我们来讨论 SAML。如果您想实现自己的 SAML SP(服务提供商),我建议使用 Spring-Security-SAML,原因与我上面推荐 Spring-security-OAuth 相同。
现在,考虑一个使用 OAuth 对用户进行身份验证的应用程序,这意味着该应用程序是一个“OAuth 资源服务器”,而它的客户端实现了 OAuth 协议,这意味着它们是“OAuth 客户端”。我被要求使该应用程序能够连接 SAML IdP(身份提供商)并在它们前面进行用户身份验证。这意味着该应用程序不仅必须支持 OAuth,还必须支持 SAML。但请注意,如果应用程序支持 SAML,则不仅应用程序本身需要进行更改,所有客户端也需要进行更改。目前客户端是“OAuth 客户端”(即它们遵循 OAuth 协议)。如果应用程序也支持 SAML,客户端也必须在它们这一侧支持它。在 SAML 中,重定向的实现方式不同,请求也不同。那么,问题是如何在不更改所有客户端的情况下,让这个应用程序支持 SAML 呢?
解决方案是创建一个应用程序(“桥梁”),它将成为 OAuth 和 SAML 之间的桥梁。当一个未经授权的客户端尝试访问受保护的资源时,它会被重定向到授权服务器(这是 OAuth 的工作方式)。但关键在于:从客户端的观点以及从应用程序本身的观点来看,这个桥梁都作为一个有效的“OAuth 授权服务器”运行。因此,无需更改任何内容,无论是客户端代码还是应用程序代码。另一方面,这个服务器不会弹出用户名和密码的对话框,而是作为一个 SP 运行,并将用户重定向到预先配置的 IdP 进行身份验证。
开始动手
OAuth 的工作原理
让我们从简要介绍 OAuth 的工作原理开始。OAuth 支持多种流程(称为“授权许可”);本文档将讨论称为“授权码”的许可。其他许可也可以类似地实现。
来自 规范
在“授权码”流程中,授权码是通过使用授权服务器作为客户端和资源所有者之间的中介来获得的。客户端不直接向资源所有者请求授权,而是(通过其用户代理 = 浏览器)将资源所有者重定向到授权服务器,后者又将资源所有者带回客户端,并携带授权码。
在将授权码带回客户端之前,授权服务器会验证资源所有者并获得授权。由于资源所有者仅与授权服务器进行身份验证,因此资源所有者的凭据永远不会与客户端共享。
授权码提供了几个重要的安全优势,例如能够对客户端进行身份验证,以及将访问令牌直接传输给客户端而不经过资源所有者的用户代理,从而避免了暴露给他人,包括资源所有者。
SAML 的工作原理
当用户想通过 Web 浏览器访问受保护的资源(= 服务提供商,或 SP)时,他会导航到该网页。SP 不会要求输入用户名和密码来登录,而是将浏览器重定向到 IdP 进行身份验证。
在此重定向消息中(作为 SAMLRequest
参数)嵌入了一个 SAML 身份验证请求消息。由于 SAML 基于 XML,完整的身份验证请求消息会被压缩(以节省 URL 空间)并进行编码(因为许多字符不允许在 URL 中出现)。
当 IdP 收到此消息并决定授予 SP 的请求时,它会通过要求用户输入其凭据来验证用户(除非他已经登录——例如,当先前已登录另一个服务时——在这种情况下,单点登录会通过简单地跳过身份验证来触发)。成功身份验证后,用户的浏览器将被发送回 SP,返回到所谓的 AssertionConsumerService URL。与之前一样,一条 SAML 协议消息随之而来——这次携带的是 SAML 身份验证响应消息。
Spring 配置
如前所述,Spring 的优势之一在于其灵活性和可配置性。可以使用最少的 POJO 类开发 OAuth 或 SAML 项目;大部分工作是通过 bean 配置完成的。由开发人员决定 Spring 的基本实现是否适合他,或者他是否应该以不同的方式配置应用程序,甚至实现自己的类。
OAuth 授权服务器的 Spring 配置(XML bean 文件)看起来是这样的(我只粘贴了相关的 bean)
<!-- Protect the /oauth/token url to allow only registered clients -->
<security:http pattern="/oauth/token"
authentication-manager-ref="clientAuthenticationManager">
<security:intercept-url pattern="/oauth/token"
access="ROLE_CLIENT" requires-channel="https"/>
<security:anonymous enabled="false" />
<security:http-basic />
<security:custom-filter
ref="clientCredentialsTokenEndpointFilter"
before="BASIC_AUTH_FILTER" />
</security:http>
<security:http auto-config="true"
authentication-manager-ref="usersAuthManager">
<security:intercept-url pattern="/oauth/**" access="ROLE_USER" />
<security:intercept-url pattern="/**" access="IS_AUTHENTICATED_FULLY"/>
<security:anonymous enabled="false"/>
</security:http>
<security:authentication-manager alias="usersAuthManager">
<security:authentication-provider user-service-ref="userDetailsService"/>
</security:authentication-manager>
<security:user-service id="userDetailsService">
<security:user name="demo1@company.com"
password="pass" authorities="ROLE_USER" />
<security:user name="demo2@company.com"
password="demo" authorities="ROLE_USER" />
</security:user-service>
<bean id="clientCredentialsTokenEndpointFilter"
class="org.springframework.security.oauth2.provider.
client.ClientCredentialsTokenEndpointFilter">
<property name="authenticationManager" ref="clientAuthenticationManager" />
</bean>
<!-- OAuth2 Configuration -->
<oauth:authorization-server
client-details-service-ref="clientDetails"
token-services-ref="watchdoxAuthorizationServerTokenServices"
user-approval-handler-ref="automaticUserApprovalHandler">
<oauth:authorization-code />
<oauth:implicit />
<oauth:refresh-token />
<oauth:client-credentials />
<oauth:password />
</oauth:authorization-server>
<security:authentication-manager id="clientAuthenticationManager">
<security:authentication-provider user-service-ref="clientDetailsUserService" />
</security:authentication-manager>
<bean id="clientDetailsUserService"
class="org.springframework.security.oauth2...ClientDetailsUserDetailsService">
<constructor-arg ref="clientDetails" />
</bean>
<bean id="clientDetails"
class="org.springframework.security.oauth2...JdbcClientDetailsService">
<constructor-arg ref="dataSource" />
</bean>
<bean id="dataSource" ...>
… DataSource configurations here
</bean>
SAML SP 的 Spring 配置 可以在 Spring-Security-SAML-sample 项目中找到,因此我不会在此处包含。
现在,我们需要了解如何集成这两个项目。
集成 Spring 的 OAuth 和 SAML
如前所述,我们的应用程序是一个 OAuth 授权服务器。我们希望将此应用程序配置为 SAML SP。通过这样做,每次用户被重定向到我们的应用程序进行身份验证时,我们都会再次将其重定向到 SAML IdP。从技术上讲,重定向用户到 IdP 意味着创建一个 SAML 请求。因此,我们的应用程序必须能够处理来自该 IdP 的 SAML 响应。
导入 saml-securityContext.xml
从我们应用程序的 beans.xml 文件中,我们需要添加 saml-securityContext.xml 中声明的所有 SAML bean。
<import resource="classpath:saml-securityContext.xml"/>
SAML 入口点
SAML 属性中首先要注意的是,在主 http 块中有一个入口点的声明:
entry-point-ref="samlEntryPoint"
通常,这意味着每当相关的 http 请求尝试访问此应用程序时,此入口点都会处理该请求。在 SAML 的情况下,samlEntryPoint
(类 SAMLEntryPoint
)会检查 IdP 是否已知;如果未知,它会启动发现过程,否则它会初始化 SSO 过程——这会生成 SAML 请求到 IdP。
显然,我们需要这个功能。因此,我们将 Web 应用程序声明为“受保护的资源”,以便每次用户尝试身份验证时,都会发生 SAML 过程。我们通过更改处理 /oAuth/authorize 调用的 http 块,并将其端点过滤器设置为指向“samlEntryPoint
”来实现这一点。
<security:http entry-point-ref="samlEntryPoint">
用户认证管理器
正如你在上面的代码中看到的,我们讨论的同一个 http 块也指向一个认证管理器
...authentication-manager-ref="usersAuthManager">
这个认证管理器负责用户的身份验证。在其中,有一个提供商的声明,该提供商保存用户和密码。当然,在实际实现中,这个提供商会指向一个 JDBC,其中存储了所有用户及其加密密码。好消息是,我们不必处理任何用户和密码,因为我们将其委托给 IdP。因此,我们更改服务器的声明,使其指向 SAML SP 的认证管理器:这个管理器有一个提供商(SAMLAuthenticationProvider
或其他继承它的类)。这个类实现了 authenticate()
方法,该方法尝试对 Authentication
对象进行身份验证。
MetadataGeneratorFilter
这是一个自动生成默认 SP 元数据的过滤器。要将其包含在我们的过滤器链中,它必须被声明为我们上面看到的 http 块链中的第一个元素。
<security:custom-filter before="FIRST" ref="metadataGeneratorFilter"/>
SAML 过滤器链
使用 Spring-SAML 需要将几个过滤器添加到链中,例如注销过滤器、元数据显示过滤器以及其他一些过滤器。Spring-SAML 创建了一个专用的链,该链应该紧跟在 BASIC_AUTH_FILTER
之后运行。所以,在 http 块中(是的,还是同一个…),我们添加以下内容:
<security:custom-filter after="BASIC_AUTH_FILTER" ref="samlFilter"/>
总结
我们只进行这些更改;所有其他 bean 保持不变。
<import resource="classpath:saml-securityContext.xml"/>
<security:http authentication-manager-ref="authenticationManager"
entry-point-ref="samlEntryPoint">
<security:intercept-url pattern="/oauth/**" access="ROLE_USER" />
<security:intercept-url pattern="/**" access="IS_AUTHENTICATED_FULLY"/>
<security:anonymous enabled="false"/>
<security:custom-filter before="FIRST" ref="metadataGeneratorFilter"/>
<security:custom-filter after="BASIC_AUTH_FILTER" ref="samlFilter"/>
</security:http>
令牌
有些人可能会问——嘿,令牌呢?
啊!
到目前为止,一切都很好;Spring SAML 确保我们在 SecurityContextHolder
中有一个有效的 Authentication 对象(实际上,它是 ExpiringUsernameAuthenticationToken
类型); thanks to this, we are able to get the authorization code and then the access token. The user is happy, and we are all happy. But what happens when we want to get some information from the SAML token (e.g. the user’s name or email) and create an oAuth token that contains them? In this case (not a rare one, actually) we need to become a bit more familiar with how SAMLAuthenticationProvider
works.
SAMLAuthenticationProvider
is autowired to the authentication manager thanks to the SAML-securityContext.xml (as discussed above). The authentication manager calls the SAMLAuthenticationProvider.authenticate()
method and passes it the Authentication object, which is of type SAMLAuthenticationToken
. This method validates the token, parses it and makes all necessary checks to make sure it corresponds to the SAMLRequest
. Then – and this is the interesting part– it tries to load the user’s details from the SAML credentials (parsed earlier from the response). How does this happen? It checks if there is a SAMLUserDetailsService
object available; if there is none, it returns null (as it did in our case till now) and this is fine, but it does not use the details from the SAML token. If, however, there is a SAMLUserDetailsService
object, it calls its loadUserBySaml()
method.
Now, we have to ensure that there is a SAMLUserDetailsService
object attached. We do this by implementing one by ourselves, and annotating it as "Component" so it will be autowired to the SAMLAuthenticationProvider
.
/**
* this class is autowired to the SamlProvider, so it tries to get the user's details from the token using this
* class, o/w it returns null.
* @author Ohad
*
*/
@Component
public class SAMLUserDetailsServiceImpl implements SAMLUserDetailsService
{
@Override
public Object loadUserBySAML(SAMLCredential credential)
throws UsernameNotFoundException
{
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
String email = credential.getNameID().getValue();
GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_USER");
authorities.add(authority);
UserDetails userDetails = new User(
email, "password", true, true, true, true, authorities);
return userDetails;
}
}
The provider then takes the UserDetails
object and sets it to the Authentication object that it creates. Now we have access to these details so we can call the SecurityContextHolder.getAuthentication()
and get them. When we do this? Spring’s TokenEndpoint.getAccessToken()
calls implicitly to the tokenServices bean, to create the access token. Spring’s default token-services is DefaultTokenServices
, which does not use the ‘name’ field of the given Authentication object. (Actually, it uses a random UUID). To overcome this, we create our own token-service that will do this work. Note that its bean name has to be attached properly in the XML file
<!-- OAuth2 Configuration -->
<oauth:authorization-server
token-services-ref="myAuthorizationServerTokenServices"
...
Below is an example for such token service
@Component("myAuthorizationServerTokenServices")
public class OAuth2TokenServices implements AuthorizationServerTokenServices, InitializingBean
{
@Override
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException
{
String email = authentication.getName();
String generatedToken = generate the Token with the 'email'
DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken( generatedToken );
result.setExpiration( expiration );
result.setTokenType("Bearer");
...
return result;
}
}
总结
We have seen how to integrate two different Spring projects, each handling a different authentication mechanism, and by integrating them have achieved a bridge that, from the clients’ side, remains an oAuth authentication server, but allows the application to connect and authenticate in front of SAML IdPs.
Many thanks to David Goldhar for his help with this document!