Spring OAuth2 授权服务器搭建SSO单点登录实践

授权服务器版本:

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.2.0</version>
</dependency>

👉 Spring 授权服务器 GitHub
源码里有几个案例比较适合上手

使用 spring-security-oauth2-authorization-server 搭SSO单点登录需要对SpringSecurity和OAuth2有比较深入的了解。

单点的SpringSecurity配置方面坑不多,值得一提的就是

1
java复制代码http.authorizeRequests().antMatchers("/login/**").permitAll()

而不能是

1
java复制代码http.authorizeRequests().antMatchers("/login").permitAll()

或者

1
java复制代码http.formLogin().permitAll()

因为spring-security-oauth2-authorization-server对OAuth2标准的重定向都是使用各种savedRequest,使用HttpSessionRequestCache缓存,然后结束后跳转,由于security 对于所有不公开访问的 url 都会进行 saveRequest 操作 所以 这里必须放开/login/** 而不是/login,否则错误页面/login?error 也会被缓存至 savedRequest 中,导致用户输错密码时会反复登录,或者覆盖掉oauth2的重定向回调

其他就按照官方案例老老实实配就行,一共两个SpringSecurity的配置,另外一个就是常见Security配置,配置一些登录、登出、rememberMe、cors、csrf之类的就不展示了,在用的OIDC的Security配置如下

1
2
3
4
5
6
7
java复制代码    @Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.formLogin(withDefaults()).logout().and().cors();
return http.build();
}

OIDC登录会重定向至默认Security的登录页面,具体的用户登录判断是在默认Security设置的。

单点登出

重点是logout , OidcProviderConfigurationEndpointFilter 会注册一个标准协议中的/.well-known/openid-configuration端点,但是这个版本的spring授权服务器这个端点返回的元数据不包括end_session_endpoint的信息,故需要客户端自定义登出操作。

客户端如果需要单点登出,就参考org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler自己实现一个,把endSessionEndpoint方法修改一下(或者偷懒让前端发两个登出请求(笑),再或者内部用WebClinet发一个请求到单点服务器上,不让用户跳转了)

1
2
3
4
5
6
7
8
9
10
11
java复制代码    private URI endSessionEndpoint(ClientRegistration clientRegistration) {
if (clientRegistration != null) {
ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
Object endSessionEndpoint = providerDetails.getConfigurationMetadata().get("issuer");//oidc认证端点的网址
if (endSessionEndpoint != null) {
//组装自己的单点的登出请求地址,可以和SSO的普通登出地址不一致
return URI.create(endSessionEndpoint + "/logout");
}
}
return null;
}

然后在客户端的Security配置里把successHandler注册进去

1
2
3
java复制代码.logout(logout ->
logout.logoutSuccessHandler(mySsoLogoutSuccessHandler))
)

然后在单点服务器这边,如果没有启用csrf那么LogoutFilter会拦截所有请求,如果开启了只会拦截POST,而客户端发起的单点登出实际上是由浏览器的重定向请求(如果登出请求使用Ajax发出的那么浏览器只会发出请求但是不会跳转页面,需要刷新一下页面),且请求中包含了jwt可以通过SpringSecurity的授权过滤器

如果开启csrf,那么需要额外的配置:增加一个登出的controller,因为客户端发起的重定向连接如果没有被LogoutFilter捕获(捕获也就直接登出了)就会到达controller层 在controller层登出是非常简单的,因为org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter对Request进行了包装,直接HttpServletRequest.logout()即可

1
2
3
4
5
6
java复制代码@GetMapping("/logout")
@ResponseBody
public Result<String> logoutWithJson(HttpServletRequest request) throws ServletException {
request.logout();
return Result.ok("登出成功");//Result是我自己的返回类
}

传递用户数据

授权服务器搭起来是比较简单的,坑比较多的是客户端,如果想要客户端通过单点共享用户数据,比如共享一个自定义的UserDetails的实现类UserEntity,但是客户端通过Oauth2登录,SecurityContextHolder.getContext().getAuthentication().getPrincipal()就不是UserDetails的子类而是OidcUser的子类,需要实现一个自定义的OidcUserUserEntity组装进去,传递UserDetails可以使用资源服务器形式,但是如果需要在客户端的登录认证阶段就把UserEntity特别是其中的authorities加载好,就需要自己实现一个OidcUserService去发出http请求加载,而且不能使用正常的资源服务器的Oauth2请求流程,而是拿着OIDC的AccessToken去找单点交换资源,否则会登录一半重新跳转到资源服务器的登录。

而且UserEntity不能直接被jackson JSON序列化传输,否则要么authorities不序列化,要么SpringSecurity无法加载UserEntity,需要自定义个一个专门用来传输的UserEntityDTO

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
java复制代码public class SsoOidcUserService extends OidcUserService {
private WebClient webClient;
public SsoOidcUserService(OAuth2AuthorizedClientManager authorizedClientManager){
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
webClient = WebClient.builder()
.apply(oauth2Client.oauth2Configuration()).build();
}
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(userRequest);
UserEntity userEntity = getSsoUserEntity(new OAuth2AuthorizedClient(userRequest.getClientRegistration(),oidcUser.getName(),userRequest.getAccessToken()));
//....
}
public UserEntity getSsoUserEntity(OAuth2AuthorizedClient oAuth2AuthorizedClient) {
UserEntity entity = null;
try {
entity = UserEntityDTO.toUserEntity(webClient
.get()
.uri(oAuth2AuthorizedClient.getClientRegistration().getProviderDetails().getIssuerUri()+"/你的User rest请求端点")
.attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient(oAuth2AuthorizedClient))
.retrieve()
.bodyToMono(UserEntityDTO.class)
.block());
} catch (Exception e) {
e.printStackTrace();
}
return entity;
}
}

前后端分离

然后就是前后端分离的一些问题,前后端分离,访问请求是Ajax发起的,但是Ajax发起的请求不能被302重定向,需要和前端约定好客户端需要登录时返回JSON,让前端自行重定向。后端实现一个AuthenticationEntryPoint塞到SpringSecurity设置里。 这点跟postman很像,postman测试单点也是不行的,重定向请求会直接后台默默转发,直到401,点一下postman,以为只请求了一次,实际上postman控制台显示发出了n次请求,没有一点提示,也很坑

然后就是登录成功后跳转的问题,Spring OAuth2 登陆完会从HttpSessionRequestCache取出savedRequest重定向,这样的问题就是登陆完,页面重定向到了后端接口,显示一大堆JSON数据,这样肯定是不行的,可以自定义AuthenticationSuccessHandler,塞到loginSuccessHandler里,跳转到固定页面,或者还是在AuthenticationEntryPoint里,和前端约定好请求里都带上一个自定义的header,值是JS代码window.location.href,然后在org.springframework.security.web.AuthenticationEntryPoint#commence方法执行的时候从header里面取出来,替换掉Session中的savedRequest,替换方法就不展示了,HttpSessionRequestCache怎么存的就怎么替换。

然后就是Nginx反向代理,代理时需要设置

1
bash复制代码proxy_set_header Host   $http_host;

否则org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint#buildRedirectUrlToLoginPage方法里request.getServerName()就会取到的是Nginx主机地址而不是原始host地址

顺带一提,如果开发阶段用的IP地址测试,和域名之间是不同的,如果用了域名,但是不用Nginx反向代理,前端页面、后台、单点登录三者不同端口号会导致Cookies不能正确传递,一登录完就丢失会话信息,但是数字ip的不同端口号却又能正确传递Cookies…

开发用了很久,开发完了,部署阶段也被坑了好多次,花了一个星期时间才算部署上去

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%