Spring Security 解析(六) —— 基于JWT的单点登录(SSO)开发及原理解析

Spring Security 解析(六) —— 基于JWT的单点登录(SSO)开发及原理解析

  在学习Spring Cloud 时,遇到了受权服务oauth 相关内容时,老是只知其一;不知其二,所以决定先把Spring Security 、Spring Security Oauth2 等权限、认证相关的内容、原理及设计学习并整理一遍。本系列文章就是在学习的过程当中增强印象和理解所撰写的,若有侵权请告知。html

项目环境:java

  • JDK1.8
  • Spring boot 2.x
  • Spring Security 5.x

  单点登陆(Single Sign On),简称为SSO,是目前比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只须要登陆一次就能够访问全部相互信任的应用系统。 单点登录本质上也是OAuth2的使用,因此其开发依赖于受权认证服务,若是不清楚的能够看个人上一篇文章。git

1、 单点登录 Demo开发

  从单点登录的定义上来看就知道咱们须要新建个应用程序,我把它命名为 security-sso-client。接下的开发就在这个应用程序上了。github

1、Maven 依赖

  主要依赖 spring-boot-starter-security、spring-security-oauth2-autoconfigure、spring-security-oauth2 这3个。其中 spring-security-oauth2-autoconfigure 是Spring Boot 2.X 才有的。redis

<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.springframework.data.redis.connection.RedisConnection.set([B[B)V
          要求必须大于2.3.5 版本,官方解释:https://github.com/BUG9/spring-security/network/alert/pom.xml/org.springframework.security.oauth:spring-security-oauth2/open
          -->
          <version>2.3.5.RELEASE</version>
      </dependency>
复制代码

2、单点配置 @EnableOAuth2Sso

  单点的基础配置引入是依赖 @EnableOAuth2Sso 实现的,在Spring Boot 2.x 及以上版本 的 @EnableOAuth2Sso 是在 spring-security-oauth2-autoconfigure 依赖里的。我这里简单配置了一下:spring

@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 设置成无权限访问。浏览器

3、测试接口及页面

测试接口
@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="http://localhost:8091/client/1">跳转到OSS-client-1</a>
  <a href="http://localhost:8092/client/2">跳转到OSS-client-2</a>
  </body>
  </html>
复制代码

4、单点配置文件配置受权信息

   因为咱们要测试多应用间的单点,因此咱们至少须要2个单点客户端,我这边经过Spring Boot 的多环境配置实现。bash

application.yml 配置

   咱们都知道单点实现本质就是Oauth2的受权码模式,因此咱们须要配置访问受权服务器的地址信息,包括 :服务器

  • security.oauth2.client.user-authorization-uri = /oauth/authorize 请求认证的地址,即获取code 码
  • security.oauth2.client.access-token-uri = /oauth/token 请求令牌的地址
  • security.oauth2.resource.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.servlet.session.cookie.name = OAUTH2CLIENTSESSION 解决单机开发存在的问题,若是是非单机开发可忽略其配置
auth-server: http://localhost:9090 # 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:
      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
    
复制代码

application-client1.yml 配置

   application-client2 和 application-client1是同样的,只是端口号和client信息不同而已,这里就再也不重复贴出了。

server:
port: 8091

security:
oauth2:
  client:
    client-id: client1
    client-secret: 123456
  
复制代码

5、单点测试

   效果以下:

https://user-gold-cdn.xitu.io/2019/9/17/16d3d5468781d4f8?w=480&h=242&f=gif&s=114834

  从效果图中咱们能够发现,当咱们第一次访问client2 的接口时,跳转到了受权服务的登录界面,完成登录后成功跳转回到了client2 的测试接口,而且展现了接口返回值。此时咱们访问client1 的 测试接口时直接返回(表面现象)了接口返回值。这就是单点登录的效果,好奇心强的同窗必定会在内心问道:它是如何实现的? 那么接下来咱们就来揭开其面纱。

2、 单点登录原理解析

1、@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 这个单点登录核心过滤器之一

2、 OAuth2ClientContextFilter

   OAuth2ClientContextFilter 过滤器相似于 ExceptionTranslationFilter , 它自己没有作任何过滤处理,只要当 chain.doFilter() 出现异常后 作出一个重定向处理。 但别小看这个重定向处理,它但是实现单点登录的第一步,还记得第一次单点时会跳转到受权服务器的登录页面么?而这个功能就是 OAuth2ClientContextFilter 实现的。咱们来看下其源码:

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)); // 一、记录当前地址(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) {  // 二、判断当前异常 UserRedirectRequiredException 对象 是否为空
				redirectUser(redirect, request, response); // 三、重定向访问 受权服务 /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

  整个 filter 分三步:

  • 一、记录当前地址(currentUri)到HttpServletRequest
  • 二、判断当前异常 UserRedirectRequiredException 对象 是否为空
  • 三、重定向访问 受权服务 /oauth/authorize

3、 OAuth2ClientAuthenticationProcessingFilter

   OAuth2ClientContextFilter 过滤器 其要完成的工做就是 经过获取到的code码调用 受权服务 /oauth/token 接口获取 token 信息,并将获取到的token 信息解析成 OAuth2Authentication 认证对象。起源以下:

@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException {

		OAuth2AccessToken accessToken;
		try {
			accessToken = restTemplate.getAccessToken(); //一、  调用受权服务获取token 
		} catch (OAuth2Exception e) {
			BadCredentialsException bad = new BadCredentialsException("Could not obtain access token", e);
			publish(new OAuth2AuthenticationFailureEvent(bad));
			throw bad;			
		}
		try {
			OAuth2Authentication result = tokenServices.loadAuthentication(accessToken.getValue()); // 二、  解析token信息为 OAuth2Authentication 认证对象并返回
			if (authenticationDetailsSource!=null) {
				request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, accessToken.getValue());
				request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, accessToken.getTokenType());
				result.setDetails(authenticationDetailsSource.buildDetails(request));
			}
			publish(new AuthenticationSuccessEvent(result));
			return result;
		}
		catch (InvalidTokenException e) {
			BadCredentialsException bad = new BadCredentialsException("Could not obtain user details from token", e);
			publish(new OAuth2AuthenticationFailureEvent(bad));
			throw bad;			
		}

	}
复制代码

   整个 filter 2点功能:

  • restTemplate.getAccessToken(); //一、 调用受权服务获取token
  • tokenServices.loadAuthentication(accessToken.getValue()); // 二、 解析token信息为 OAuth2Authentication 认证对象并返回

  完成上面步骤后就是一个正常的security受权认证过程,这里就再也不讲述,有不清楚的同窗能够看下我写的相关文章。

4、 AuthorizationCodeAccessTokenProvider

   在讲述 OAuth2ClientContextFilter 时有一点没讲,那就是 UserRedirectRequiredException 是 谁抛出来的。 在讲述 OAuth2ClientAuthenticationProcessingFilter 也有一点没讲到,那就是它是如何判断出 当前 /login 是属于 须要获取code码的步骤仍是去获取 token 的步骤( 固然是判断/login 是否带有code 参数,这里主要讲明是谁来判断的)。 这2个点都设计到了 AuthorizationCodeAccessTokenProvider 这个类。这个类是什么时候被调用的? 其实 OAuth2ClientAuthenticationProcessingFilter 隐藏在 restTemplate.getAccessToken(); 这个方法内部 调用的 accessTokenProvider.obtainAccessToken() 这里。 咱们来看下OAuth2ClientAuthenticationProcessingFilter 的 obtainAccessToken() 方法内部源码:

public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails details, AccessTokenRequest request)
			throws UserRedirectRequiredException, UserApprovalRequiredException, AccessDeniedException,
			OAuth2AccessDeniedException {

		AuthorizationCodeResourceDetails resource = (AuthorizationCodeResourceDetails) details;

		if (request.getAuthorizationCode() == null) {  //一、 判断当前参数是否包含code码 
			if (request.getStateKey() == null) {
				throw getRedirectForAuthorization(resource, request); //二、 不包含则抛出 UserRedirectRequiredException 异常
			}
			obtainAuthorizationCode(resource, request);
		}
		return retrieveToken(request, resource, getParametersForTokenRequest(resource, request),
				getHeadersForTokenRequest(request)); // 3 、 包含则调用获取token 

	}
复制代码

整个方法内部分3步:

  • 一、 判断当前参数是否包含code码
  • 二、 不包含则抛出 UserRedirectRequiredException 异常
  • 三、 包含继续获取token

   最后可能有同窗会问,为何第一个客户端单点要跳转到受权服务登录页面去登录, 而当问第二个客户端却没有,其实 2次 客户端单点的流程都是同样的,都是受权码模式,但为何客户端2 却不须要登录呢? 实际上是由于Cookies/Session的缘由,由于咱们访问同2个客户端基本上都是在同一个浏览器中进行的。 不信的同窗能够试试2个浏览器分别访问2个单点客户端。

3、 我的总结

  单点登录本质上就是受权码模式,因此理解起来仍是很容易的,若是非要给个流程图,仍是那张受权码流程图:

https://user-gold-cdn.xitu.io/2019/9/16/16d39ae2361d9d48?w=948&h=468&f=jpeg&s=172612

   本文介绍 基于JWT的单点登录(SSO)开发及原理解析 开发的代码能够访问代码仓库 ,项目的github 地址 : github.com/BUG9/spring…

         若是您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!