本文样例代码地址: 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 的区别:
- rfc7515: JSON Web Signature (JWS)
- rfc7516: JSON Web Encryption (JWE)
- rfc7517: JSON Web Key (JWK)
- rfc7518: JSON Web Algorithms (JWA)
- rfc7519: JSON Web Token (JWT)
本文使用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端案例测试中只用到两个),顺序依次如下:
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
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;
}
}
OAuth2LoginAuthenticationFilter
同UsernamePasswordAuthenticationFilter
一样都继承自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;
}
}