赞
踩
本文样例代码地址: spring-security-oauth2.0-sample
关于OAuth2.0规范介绍请参考 OAuth 2.0 Simplified
关于OAuth2.1草案介绍请参考 OAuth 2.1
关于Spring Security中OAuth2.0在前后端分离架构下的授权流程可以参考: 前后端分离:Spring Security OAuth2.0第三方授权
关于OAuth2.0 Login在Spring Security中实现基本原理,可参考官网文档: Spring Security: OAuth 2.0 Login
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授权登录为例。
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端案例测试中只用到两个),顺序依次如下:
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
OAuth2AuthorizationCodeGrantFilter
本用例只涉及前两个。
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去第三方资源服务器换受限资源等工作。
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; } }
OAuth2LoginAuthenticationFilter
同UsernamePasswordAuthenticationFilter
一样都继承自AbstractAuthenticationProcessingFilter
,所以之后的一些处理工作都以模板模式集成在父抽象类中,步骤和UsernamePasswordAuthenticationFilter
一样,主要步骤如下:
OAuth2LoginAuthenticationFilter
最终会调用该provider进行以下工作
OAuth2AuthorizationCodeAuthenticationProvider
完成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和普通的OAuth2共用Filter,但是底层的AuthenticationFilter
相同
OidcAuthorizationCodeAuthenticationProvider
首先对返回来的idToken做校验,这里需要访问 clietRegistration中设置的jwk_uri
,会先去访问这个uri获取公钥在验证idToken是否合法。
idToken验证完毕后会携带accessToken访问 clientRegistration中设置的userinfo_uri
获取用户信息,如果没配置就不访问了。
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; } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。