SpringSecurity解析(六)——基于JWT的单点登陆(SSO)开发及原理解析Spring Security 解析(六) —— 基于JWT的单点登陆(SSO)开发及原理解析
  在学习Spring Cloud 时,遇到了授权服务oauth 相关内容时,总是⼀知半解,因此决定先把Spring Security 、Spring Security Oauth2 等权限、认证相关的内容、原理及设计学习并整理⼀遍。本系列⽂章就是在学习的过程中加强印象和理解所撰写的,如有侵权请告知。
项⽬环境:
JDK1.8
Spring boot 2.x
Spring Security 5.x
  单点登录(Single Sign On),简称为SSO,是⽬前⽐较流⾏的企业业务整合的解决⽅案之⼀。 SSO的定义是在多个应⽤系统中,⽤户只需要登录⼀次就可以访问所有相互信任的应⽤系统。
单点登陆本质上也是OAuth2的使⽤,所以其开发依赖于授权认证服务,如果不清楚的可以看我的上⼀篇⽂章。
⼀、单点登陆 Demo开发
  从单点登陆的定义上来看就知道我们需要新建个应⽤程序,我把它命名为 security-sso-client。接下的开发就在这个应⽤程序上了。
⼀、Maven 依赖
  主要依赖 spring-boot-starter-security、spring-security-oauth2-autoconfigure、spring-security-oauth2 这3个。其中 spring-security-oauth2-autoconfigure 是Spring Boot 2.X 才有的。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--@EnableOAuth2Sso 引⼊,Spring Boot 2.x 将这个注解移到该依赖包-->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</exclusion>
</exclusions>
<version>2.1.7.RELEASE</version>
</dependency>
<!-- 不是starter,⼿动配置 -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<!--请注意下 spring-authorization-oauth2 的版本务必⾼于 2.3.2.RELEASE,这是官⽅的⼀个bug:
java.lang.NoSuchMethodError: org.tion.RedisConnection.set([B[B)V
要求必须⼤于2.3.5 版本,官⽅解释:github/BUG9/spring-security/network/l/org.springframework.security.oauth:spring-security-oauth2/open
-->
<version>2.3.5.RELEASE</version>
</dependency>
⼆、单点配置 @EnableOAuth2Sso
  单点的基础配置引⼊是依赖 @EnableOAuth2Sso 实现的,在Spring Boot 2.x 及以上版本的 @EnableOAuth2Sso 是在 spring-security-oauth2-autoconfigure 依赖⾥的。我这⾥简单配置了⼀下:
@Configuration
@EnableOAuth2Sso
public class ClientSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/","/error","/login").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
  因为单点期间可能存在某些问题,会重定向到 /error ,所以我们把 /error 设置成⽆权限访问。
三、测试接⼝及页⾯
测试接⼝
@RestController
@Slf4j
public class TestController {
@GetMapping("/client/{clientId}")
public String getClient(@PathVariable String clientId) {
return clientId;
}
}
测试页⾯
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OSS-client</title>
</head>
<body>
<h1>OSS-client</h1>
<a href="localhost:8091/client/1">跳转到OSS-client-1</a>
<a href="localhost:8092/client/2">跳转到OSS-client-2</a>
</body>
</html>
四、单点配置⽂件配置授权信息
  由于我们要测试多应⽤间的单点,所以我们⾄少需要2个单点客户端,我这边通过Spring Boot 的多环境配置实现。
  我们都知道单点实现本质就是Oauth2的授权码模式,所以我们需要配置访问授权服务器的地址信息,包括:
security.oauth2.client.user-authorization-uri = /oauth/authorize 请求认证的地址,即获取code 码
security.oauth2.client.access-token-uri = /oauth/token 请求令牌的地址
source.jwt.key-uri = /oauth/token_key 解析jwt令牌所需要密钥的地址,服务启动时会调⽤授权服务该接⼝获取jwt key,所以务必保证授权服务正常
security.oauth2.client.client-id = client1 clientId 信息
security.oauth2.client.client-secret = 123456 clientSecret 信息
其中有⼏个配置需要简单解释下:
security.oauth2.sso.login-path=/login OAuth2授权服务器触发重定向到客户端的路径,默认为 /login,这个路径要与授权服务器的回调地址(域名)后的路径⼀致
server.kie.name = OAUTH2CLIENTSESSION 解决单机开发存在的问题,如果是⾮单机开发可忽略其配置
auth-server: # authorization服务地址
security:
oauth2:
client:
user-authorization-uri: ${auth-server}/oauth/authorize #请求认证的地址
access-token-uri: ${auth-server}/oauth/token #请求令牌的地址
resource:
jwt:
key-uri: ${auth-server}/oauth/token_key #解析jwt令牌所需要密钥的地址,服务启动时会调⽤授权服务该接⼝获取jwt key,所以务必保证授权服务正常
sso:
springboot 原理解析
login-path: /login #指向登录页⾯的路径,即OAuth2授权服务器触发重定向到客户端的路径,默认为 /login
server:
servlet:
session:
cookie:
name: OAUTH2CLIENTSESSION # 解决 Possible CSRF detected - state parameter was required but no state could be found 问题
spring:
profiles:
active: client1
#### l 配置
   application-client2 和 application-client1是⼀样的,只是端⼝号和client信息不⼀样⽽已,这⾥就不再重复贴出了。
server:
port: 8091
security:
oauth2:
client:
client-id: client1
client-secret: 123456
#### 五、单点测试
   效果如下:
!
[media.giphy/media/VGbfzT9iK39SCxRVo5/giphy.gif](media.giphy/media/VGbfzT9iK39SCxRVo5/giphy.gif)
 &emsp;从效果图中我们可以发现,当我们第⼀次访问client2 的接⼝时,跳转到了授权服务的登陆界⾯,完成登陆后成功跳转回到了client2 的测试接⼝,并且展⽰了接⼝返回值。此时我们访问client1 的测试接⼝时直接返回(表⾯现象)了接### ⼆、单点登陆原理解析
#### ⼀、@EnableOAuth2Sso
   我们都知道 @EnableOAuth2Sso 是实现单点登陆的最核⼼配置注解,那么我们来看下 @EnableOAuth2Sso 的源码:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableOAuth2Client
@EnableConfigurationProperties(OAuth2SsoProperties.class)
@Import({ OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class,
ResourceServerTokenServicesConfiguration.class })
public @interface EnableOAuth2Sso {
}
   其中我们关注4个配置⽂件的引⽤: ResourceServerTokenServicesConfiguration 、OAuth2SsoDefaultConfiguration 、 OAuth2SsoProperties 和 @EnableOAuth2Client:
- OAuth2SsoDefaultConfiguration 单点登陆的核⼼配置,内部创建了 SsoSecurityConfigurer 对象, SsoSecurityConfigurer 内部主要是配置 **OAuth2ClientAuthenticationProcessingFilter** 这个单点登陆核⼼过滤器之⼀。
- ResourceServerTokenServicesConfiguration  内部读取了我们在 yml 中配置的信息
- OAuth2SsoProperties 配置了回调地址url ,这个就是 security.oauth2.sso.login-path=/login  匹配的
- @EnableOAuth2Client  标明单点客户端,其内部主要配置了  **OAuth2ClientContextFilter** 这个单点登陆核⼼过滤器之⼀
#### ⼆、 OAuth2ClientContextFilter
    OAuth2ClientContextFilter 过滤器类似于  ExceptionTranslationFilter , 它本⾝没有做任何过滤处理,只要当 chain.doFilter() 出现异常后做出⼀个重定向处理。但别⼩看这个重定向处理,它可是实现单点登陆的第⼀步,还记得第⼀次public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
request.setAttribute(CURRENT_URI, calculateCurrentUri(request)); // 1、记录当前地址(currentUri)到HttpServletRequest
try {
chain.doFilter(servletRequest, servletResponse);
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
UserRedirectRequiredException redirect = (UserRedirectRequiredException) throwableAnalyzer
.getFirstThrowableOfType(
UserRedirectRequiredException.class, causeChain);
if (redirect != null) {  // 2、判断当前异常 UserRedirectRequiredException 对象是否为空
redirectUser(redirect, request, response); // 3、重定向访问授权服务 /oauth/authorize
} else {
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
throw new NestedServletException("Unhandled exception", ex);
}
}
}
    Debug看下:
!
[图⽚_20190916173425.png](img2018blogs/blog/1772687/201909/1772687-20190916184617896-47896444.jpg)
 &emsp;整个 filter 分三步:
- 1、记录当前地址(currentUri)到HttpServletRequest
- 2、判断当前异常 UserRedirectRequiredException 对象是否为空
- 3、重定向访问授权服务 /oauth/authorize
#### 三、 OAuth2ClientAuthenticationProcessingFilter
   OAuth2ClientContextFilter 过滤器其要完成的⼯作就是通过获取到的code码调⽤授权服务 /oauth/token 接⼝获取 token 信息,并将获取到的token 信息解析成 OAuth2Authentication 认证对象。起源如下:
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
OAuth2AccessToken accessToken;
try {
accessToken = AccessToken(); //1、调⽤授权服务获取token
} catch (OAuth2Exception e) {
BadCredentialsException bad = new BadCredentialsException("Could not obtain access token", e);
publish(new OAuth2AuthenticationFailureEvent(bad));
throw bad;
}
try {
OAuth2Authentication result = tokenServices.Value()); // 2、解析token信息为 OAuth2Authentication 认证对象并返回
if (authenticationDetailsSource!=null) {
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, Value());
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, TokenType());
result.setDetails(authenticationDetailsSource.buildDetails(request));
}
publish(new AuthenticationSuccessEvent(result));
return result;
}
catch (InvalidTokenException e) {
BadCredentialsException bad = new BadCredentialsException("Could not obtain user details from tok
en", e);
publish(new OAuth2AuthenticationFailureEvent(bad));
throw bad;
}
}
   整个 filter 2点功能:
- AccessToken(); //1、调⽤授权服务获取token
-  tokenServices.Value());  // 2、解析token信息为 OAuth2Authentication 认证对象并返回
 &emsp;完成上⾯步骤后就是⼀个正常的security授权认证过程,这⾥就不再讲述,有不清楚的同学可以看下我写的相关⽂章。
#### 四、 AuthorizationCodeAccessTokenProvider
   在讲述 OAuth2ClientContextFilter 时有⼀点没讲,那就是  UserRedirectRequiredException 是谁抛出来的。在讲述 OAuth2ClientAuthenticationProcessingFilter 也有⼀点没讲到,那就是它是如何判断出当前 /login 是属于需要获取其实 OAuth2ClientAuthenticationProcessingFilter 隐藏在  AccessToken();  这个⽅法内部调⽤的 accessTokenProvider.obtainAccessToken() 这⾥。我们来看下OAuth2ClientAuthenticationProcessingFilter 的  obtainAccessToke public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails details, AccessTokenRequest request)
throws UserRedirectRequiredException, UserApprovalRequiredException, AccessDeniedException,
OAuth2AccessDeniedException {
AuthorizationCodeResourceDetails resource = (AuthorizationCodeResourceDetails) details;
if (AuthorizationCode() == null) {  //1、判断当前参数是否包含code码
if (StateKey() == null) {
throw getRedirectForAuthorization(resource, request); //2、不包含则抛出 UserRedirectRequiredException 异常
}
obtainAuthorizationCode(resource, request);
}
return retrieveToken(request, resource, getParametersForTokenRequest(resource, request),
getHeadersForTokenRequest(request)); // 3 、包含则调⽤获取token
}
整个⽅法内部分3步:
- 1、判断当前参数是否包含code码
- 2、不包含则抛出 UserRedirectRequiredException 异常
- 3、包含继续获取token
   最后可能有同学会问,为什么第⼀个客户端单点要跳转到授权服务登陆页⾯去登陆,⽽当问第⼆个
客户端却没有,其实 2次客户端单点的流程都是⼀样的,都是授权码模式,但为什么客户端2 却不需要登陆呢?其实是因为Cookies/ ### 三、个⼈总结
 &emsp;单点登陆本质上就是授权码模式,所以理解起来还是很容易的,如果⾮要给个流程图,还是那张授权码流程图:
![img2018blogs/blog/1772687/201909/1772687-20190916184619642-2009936552.jpg](img2018blogs/blog/1772687/201909/1772687-20190916184619642-2009936552.jpg)
   本⽂介绍基于JWT的单点登陆(SSO)开发及原理解析开发的代码可以访问代码仓库,项⽬的github 地址 : github/BUG9/spring-security
         **如果您对这些感兴趣,欢迎star、follow、收藏、转发给予⽀持!**

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。