Spring Security 源码解读:OAuth2.0 Login Client

本文样例代码地址: spring-security-oauth2.0-sample

OAuth2.0协议初衷是用来做授权的,而不是认证(特殊的授权)。在此协议的基础上进一步衍生出了 OpenID Connect (Oidc)协议专门应对OAuth2.0认证。具体关于这两个协议的区别,可以参考:

而在Spring Security中,OAuth2.0认证和 Oidc认证共用Filter,但底层使用的AuthenticationProvider不同。

另外本文在成功登录之后会发给用户一个accessToken和一个refreshToken,这两个是用来校验用户是否登录本应用的,和OAuth2去授权服务器获取的accessToken、refreshToken不是同一个。(但是用法差不多)本案例中用户登录的accessToken为JWT形式,而OAuth2授权服务器的accessToken可以为一个随机数,不必一定为JWT,但Oidc中的idToken必须为JWT。

关于 JWT/JWE/JWS/JWK/JWA 的区别:

本文使用Spring Boot 2.7.4版本,对应Spring Security 5.7.3版本,第三方登录以Gitee授权登录为例。

Introduction

Spring Security OAuth2.0 Login涉及以下关键类或接口:

两个Filter:

  • OAuth2AuthorizationRequestRedirectFilter :开始第三方登录流程。根据路径匹配,默认 /oauth2/authorization/{registration_id},如果匹配上,表示开始第三方登录,即这个filter是用来获取authorization_code的。第三方应用返回是否授权页面给浏览器,用户同意后,authorization_code会返回给该应用前端,前端将code返回给后端。前端地址为redirect_url,须在第三方应用配置,也要再本应用配置,两个要相同。这里为了方便演示,这个redirect_url我直接设成后端地址,跳过了前端传回后端步骤,而这个接受的后端地址格式默认是 /login/oauth2/code/{registration_id}?code=code&state=state
  • OAuth2LoginAuthenticationFilter :包含两部分:1. 拿着authorization_code去第三方授权服务器换取 accessToken 2. 拿着 accessToken去第三方资源服务器换取资源信息 (底层使用RestTemplate)。OAuth2LoginAuthenticationFilter 通过OAuth2AuthorizationCodeAuthenticationProvider执行 操作。OAuth2LoginAuthenticationProvider 中有个 OAuth2AuthorizationCodeAuthenticationProvider 字段,后者专门用于 code换取accessToken操作。OAuth2LoginAuthenticationProvider利用OAuth2UserService<OAuth2UserRequest, OAuth2User> userService默认实现DefaultOAuth2UserService ,在获取到的accessToken基础上执行 accessToken换取资源信息操作。

两个AuthenticationProvider

  • OAuth2AuthorizationCodeAuthenticationProvider
  • OAuth2LoginAuthenticationProvider

关于OAuth2LoginAuthenticationFilter的UML图如下:
请添加图片描述

配置

再来看看SecurityFilterChain的配置:

@Configuration
@EnableMethodSecurity()
@RequiredArgsConstructor
public class SecurityConfig {
    
    
	// 以application/json形式返回错误给前端
	private final LoginFailureHandler loginFailureHandler;
    // 保存登录信息,application/json形式返回jwt
    private final OAuth2LoginSuccessHandler giteeSuccessHandler;
    // 第三方用户首次登录,可以新增到用户表,诸如此类操作
    private final DaoOAuth2AuthorizedClientService daoOAuth2AuthorizedClientService;

	...
	 @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
    
    	
    	...
		// OAuth2AuthorizationRequestRedirectFilter:
        // 根据路径匹配,默认 /oauth2/authorization/{registration_id},如果匹配上,表示开始第三方登录
        // 即,这个filter是用来获取authorization_code的。
        // 第三方应用返回是否授权页面给浏览器,用户同意后,authorization_code会返回给该应用前端,前端将code返回给后端
        // 前端地址为redirect_url,须在第三方应用配置,也要再本应用配置,两个要相同。
        // 这里为了方便演示,这个redirect_url我直接设成后端地址,跳过了前端传回后端步骤,而这个接受的后端地址格式
        // 默认必须是 /login/oauth2/code/{registration_id}?code=code&state=state。

        // OAuth2LoginAuthenticationFilter:
        // 包含两部分:1. 拿着authorization_code去第三方授权服务器换取 accessToken  2. 拿着 accessToken去第三方资源服务器换取资源信息 (底层使用restTemplate)
        // OAuth2LoginAuthenticationFilter 通过 OAuth2LoginAuthenticationProvider 执行 操作
        // OAuth2LoginAuthenticationProvider 中有个 OAuth2AuthorizationCodeAuthenticationProvider ,后者专门用于 code换取accessToken操作
        // OAuth2LoginAuthenticationProvider在OAuth2AuthorizationCodeAuthenticationProvider 获取到accessToken基础上执行 accessToken换取资源信息操作

        // 拿取code的uri模式默认为:/oauth2/authorization/{registration_id}
        // code换取accessToken和refreshToken的uri模式默认为:/login/oauth2/code/{registration_id}
        http.oauth2Login()
                .authorizationEndpoint()
                .authorizationRequestRepository(authorizationRequestRepository);
        http.oauth2Login()
                .successHandler(restSuccessHandler)
                .failureHandler(restFailureHandler)
                // 开始认证,默认 /oauth2/authorization/{registration_id} 不要带后面{}的东西
                .authorizationEndpoint().baseUri("/oauth2/auth")
                .and()
                // 后端接受code的地址,拿到code去换accessToken和userInfo,默认 /login/oauth2/code/* 星号不能省略,使用AntMatch,参见 AbstractAuthenticationProcessingFilter#setFilterProcessesUrl
                .redirectionEndpoint().baseUri("/login/oauth2/code/*")
//                .and()
//                .tokenEndpoint().accessTokenResponseClient() //
                .and()

                .userInfoEndpoint()
                // 重写普通OAuth2的OAuth2UserService, 默认DefaultOAuth2UserService
                .userService(new CustomOAuth2UserService())
                // 设置Oidc的OAuth2UserService, OidcUserService中组合了DefaultOAuth2UserService
                .oidcUserService(new OidcUserService())
//                // 针对认证成功的用户,调用OAuth2AuthorizedClientRepository的
//                // 默认实现类AuthenticatedPrincipalOAuth2AuthorizedClientRepository中的
//                // OAuth2AuthorizedClientService (默认Inmemory)存储
//                // 否则
//                // 匿名存储调用OAuth2AuthorizedClientRepository的另一个实现类用session存储
//                .authorizedClientRepository(...)
                .and()
                .authorizedClientService(daoOAuth2AuthorizedClientService);
				...


	}
}

源码

实际上,Security 提供OAuth2.0相关 Filter 共有三个(本client端案例测试中只用到两个),顺序依次如下:

  1. OAuth2AuthorizationRequestRedirectFilter
  2. OAuth2LoginAuthenticationFilter
  3. OAuth2AuthorizationCodeGrantFilter

本用例只涉及前两个。

OAuth2AuthroizationRequestRedirectFilter

public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
    
    

	//默认oauth2请求开始地址 :/oauth2/authorization/{registration_id}
	public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";
	// 解析路径,判断路径是否符合上面的模式
	private OAuth2AuthorizationRequestResolver authorizationRequestResolver;
	// 由于OAuth2 Login涉及多个步骤和回调,所以In-flight的请求都会放在一个地方暂存。
	private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();
	// 重定向策略,
	private final RedirectStrategy authorizationRedirectStrategy = new DefaultRedirectStrategy();

	...
	
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain){
    
    
		// 先解析路径
		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
		if (authorizationRequest != null) {
    
    
				// 重定向
				this.sendRedirectForAuthorization(request, response, authorizationRequest);
				return;
		}
		...
			
	}
	
	private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
		OAuth2AuthorizationRequest authorizationRequest) throws IOException {
    
    
		// authorization_code模式涉及多个步骤,所以先暂存请求。
		// 默认使用httpSession,在tomcat中就是一个内存的ConcurrentHashMap
		// 这里redirect会返回一个state属性,这个this.authorizationRequestRepository保存了state 和正在进行OAuth2登录的client的对应关系,后续会进行校验,这个state属性用于防止CSRF攻击
		if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
    
    
			this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
		}
		// 发送redirect
		this.authorizationRedirectStrategy.sendRedirect(request, response,
				authorizationRequest.getAuthorizationRequestUri());
	}
	...

}

在用户同意授权后,Gitee会返回authorization_code授权码到本应用前端 ( i.e. redirect_url ),本应用前端返回authorization_code到本应用后端,那么,此时,上面的 OAuth2AuthorizationRequestRedirectFilter会先拦截,但是路径不匹配,会直接跳过,这是就来到了OAuth2LoginAuthenticationFilter过滤器,这个OAuth2LoginAuthenticationFilter完成了剩下的 authorization_code换accessToken,accessToken去第三方资源服务器换受限资源等工作。

OAuth2LoginAuthenticationFilter

public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
    

	// 默认匹配格式
	public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";

	/**
	 * 模板模式,doFilterInternal方法在父类AbstractAuthenticationProcessingFilter中
	 * 该方法主要分为以下几个步骤:
	 * 1. 从request中解析出 authorization_code和state等信息
	 * 2. 根据authorization_code换取首先资源
	 * 而2中步骤委托给了 OAuth2LoginAuthenticationProvider 完成
	 * 在OAuth2LoginAuthenticationProvider中又分为两步:
	 * 2.1 根据authorization_code换取accessToken,该步骤又委托给OAuth2AuthorizationCodeAuthenticationProvider完成
	 * 2.2 根据accessToken去资源服务器换取受限资源
	 **/
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
    
    
		// 第一步:解析request中code等值
		MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
		// 判断携带的参数是否符合OAuth2.0规范,不是则抛异常
		// 规则:成功时必须包含state和code键值,失败时state和error键值,否则就不是OAuth的请求
		if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
    
    
			OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
	
		// 这里面是通过传回来的state的属性获取OAuth2AuthorizationRequest
		// 这个state属性在之前OAuth2AuthroizationRequestRedirectFilter中保存在了AuthorizationRequestRepository中
		// 如果不存在,说明可能存在CSRF攻击
		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
				.removeAuthorizationRequest(request, response);
		// 如果记录没有被记录过,抛异常
		if (authorizationRequest == null) {
    
    
			OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		// 获取registration_id,本例中即等于 gitee
		String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
		ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
		if (clientRegistration == null) {
    
    
			OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
					"Client Registration not found with Id: " + registrationId, null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		// @formatter:off
		String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
				.replaceQuery(null)
				.build()
				.toUriString();
		// @formatter:on
		// 以上都是之前的一些步骤的结果,将这些结果汇总称一个OAuth2AuthorizationResponse
		OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
				redirectUri);
		Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
		// 生成待authenticate的Authentication
		// OAuth2LoginAuthenticationToken是Authentication接口实现类
		OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
				new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
		authenticationRequest.setDetails(authenticationDetails);
		// 第二步:code换取资源
		// 这里的AuthenticationManager实现类ProviderManager中起作用的AuthenticationProvider的子类OAuth2LoginAuthenticationProvider
		OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
				.getAuthenticationManager().authenticate(authenticationRequest);
		// 将OAuth2LoginAuthenticationToken转化为OAuth2AuthenticationToken
		// 注意:转化后的OAuth2AuthenticationToken就不含有授权服务器发放的accessToken和refreshToken了
		// 后续SecurityContext持有的和AuthenticaitonSuccessHandler中传入的都是OAuth2AuthenticationToken
		// 如果要保存accessToken和refreshToken,将在下面的this.authorizedClientRepository中保存
		OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
				.convert(authenticationResult);
		Assert.notNull(oauth2Authentication, "authentication result cannot be null");
		oauth2Authentication.setDetails(authenticationDetails);
		OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
				authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
				authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());
		// 记录被认证的用户
		// OAuth2AuthorizedClientRepository默认实现为AuthenticatedPrincipalOAuth2AuthorizedClientRepository
		// AuthenticatedPrincipalOAuth2AuthorizedClientRepository如果被认证成功则调用OAuth2AuthorizedClientService(默认Inmemory存储)保存,
		// 没有被认证则匿名方式存储
		// 调用OAuth2AuthorizedClientRepository anonymousAuthorizedClientRepository = new HttpSessionOAuth2AuthorizedClientRepository()
		
		this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
		return oauth2Authentication;
	}


}

OAuth2LoginAuthenticationFilterUsernamePasswordAuthenticationFilter一样都继承自AbstractAuthenticationProcessingFilter,所以之后的一些处理工作都以模板模式集成在父抽象类中,步骤和UsernamePasswordAuthenticationFilter一样,主要步骤如下:
在这里插入图片描述

OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationFilter最终会调用该provider进行以下工作

  • code换accessToken:交给OAuth2AuthorizationCodeAuthenticationProvider完成
  • accessToken换userInfo : 默认在 DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User>中完成,如果是Oidc请求,则默认在OidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser>中完成,均可自定义配置。
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
    
    
	// code换accessToken由它代理完成
	private final OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider;
	// 将受限资源的userInfo转化为OAuth2User,最后会放入 OAuth2AuthenticationToken implements Authentication
	private final OAuth2UserService<OAuth2UserRequest, OAuth2User> userService;
	// 根据获取的OAuth2User的权限进一步自定义权限,这里是一个匿名类,直接返回
	// 重写该实现,只需将GrantedAuthoritiesMapper自定义实现注入Bean容器即可
	private GrantedAuthoritiesMapper authoritiesMapper = ((authorities) -> authorities);
	...
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    
    
		// 这个方法返回的是下面的OAuth2LoginAuthenticationToken
		// 注意:但是在AuthenticationSuccessHandler中方法参数却是 OAuth2AuthenticationToken 类型
		OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
		// Section 3.1.2.1 Authentication Request -
		// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest scope
		// REQUIRED. OpenID Connect requests MUST contain the "openid" scope value.
		// 如果时openID协议认证,交由 OidcAuthorizationCodeAuthenticationProvider等相关provider认证。
		if (loginAuthenticationToken.getAuthorizationExchange().getAuthorizationRequest().getScopes()
				.contains("openid")) {
    
    
			// This is an OpenID Connect Authentication Request so return null
			// and let OidcAuthorizationCodeAuthenticationProvider handle it instead
			return null;
		}
		OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
		try {
    
    
			// 拿着code换取accessToken
			authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
					.authenticate(new OAuth2AuthorizationCodeAuthenticationToken(
							loginAuthenticationToken.getClientRegistration(),
							loginAuthenticationToken.getAuthorizationExchange()));
		}
		catch (OAuth2AuthorizationException ex) {
    
    
			...
		}
		// 拿到的accessToken
		OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
		Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
		// DefaultOAuth2UserService中使用accessToken获取userInfo
		OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
				loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
		// 根据获取的权限自定义权限
		Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
				.mapAuthorities(oauth2User.getAuthorities());
		OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(...);
		return authenticationResult;
	}

	...
}

Oidc

Oidc和普通的OAuth2共用Filter,但是底层的AuthenticationFilter相同
在这里插入图片描述
OidcAuthorizationCodeAuthenticationProvider首先对返回来的idToken做校验,这里需要访问 clietRegistration中设置的jwk_uri,会先去访问这个uri获取公钥在验证idToken是否合法。
idToken验证完毕后会携带accessToken访问 clientRegistration中设置的userinfo_uri获取用户信息,如果没配置就不访问了。

OidcAuthorizationCodeAuthenticationProvider

public class OidcAuthorizationCodeAuthenticationProvider implements AuthenticationProvider {
    
    
	
	private final OAuth2UserService<OidcUserRequest, OidcUser> userService;

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    
    
		OAuth2LoginAuthenticationToken authorizationCodeAuthentication = (OAuth2LoginAuthenticationToken) authentication;
		
		// 验证scope中是否有 openid,没有就不是oidc认证
		if (!authorizationCodeAuthentication.getAuthorizationExchange().getAuthorizationRequest().getScopes()
				.contains(OidcScopes.OPENID)) {
    
    
			// This is NOT an OpenID Connect Authentication Request so return null
			// and let OAuth2LoginAuthenticationProvider handle it instead
			return null;
		}
		...
		// 拿着code获取accessToken和idToken
		OAuth2AccessTokenResponse accessTokenResponse = getResponse(authorizationCodeAuthentication);
		ClientRegistration clientRegistration = authorizationCodeAuthentication.getClientRegistration();
		Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
		// 校验返回的参数中是否有idToken
		if (!additionalParameters.containsKey(OidcParameterNames.ID_TOKEN)) {
    
    
			OAuth2Error invalidIdTokenError = new OAuth2Error(...);
			throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
		}
		// 这里根据返回的idToken的jwt进行校验,校验公钥来自jwk_uri
		OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
		validateNonce(authorizationRequest, idToken);
		// 拿着idToken等信息访问userinfo_uri获取userinfo
		OidcUser oidcUser = this.userService.loadUser(new OidcUserRequest(clientRegistration,
				accessTokenResponse.getAccessToken(), idToken, additionalParameters));
		...
		return authenticationResult;
	}

}



猜你喜欢

转载自blog.csdn.net/weixin_41866717/article/details/128889475