社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
优质文章,及时送达
前言
OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。网上有很多关于OAuth协议的讲解,这里就不在详细解释OAuth相关的概念了,请读者自行查阅相关资料,否则本文接下来的内容可能会很难理解。 Spring-Security对OAuth2.0的支持 截止到本文撰写的日期为止,Spring已经提供了对OAuth提供的支持( spring-security-oauth:https://github.com/spring-projects/spring-security-oauth ),但是该工程已经被废弃了,因为Spring-Security工程提供了最新的OAuth2.0支持。如果你的项目中使用了过期的Spring-Security-OAuth,请参考《 OAuth 2.0迁移指南:https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide》 , 本文将对OAuth2.0中的客户端模式进行原理分析,结合Spring官方指南中提供了一个简单的基于spring-boot与oauth2.0集成第三方应用登录的案例( spring-boot-oauth2:https://spring.io/guides/tutorials/spring-boot-oauth2/ ),一步一步分析其内部实现的原理。 公众号同样发布过近百篇 Spring Boot 相关的实战文章,关注微信公众号 Java后端,回复 666 下载这本技术栈手册。<dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-oauth2-clientartifactId>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-securityartifactId>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-webartifactId>dependency>
然后在配置文件中填上刚刚注册的应用的clientId和clientSecret:
spring:security:oauth2:client:registration:github:clientId: github-client-idclientSecret: github-client-secret
紧接着就像普通的spring-security应用一样,继承WebSecurityConfigurerAdapter,进行一些简单的配置即可
:
@SpringBootApplication@RestController
public class SocialApplication extends WebSecurityConfigurerAdapter {@Override
protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests(a -> a
.antMatchers("/", "/error", "/webjars/**").permitAll()
.anyRequest().authenticated()
).exceptionHandling(e -> e
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
).oauth2Login();
}
}
也就是说我们只需要添加maven依赖以及继承WebSecurityConfigurerAdapter进行一些简单的配置,一个oauth2客户端应用就构建完成了。接下来按照指南上的步骤点击页面的github登录链接我们的页面就会跳转到github授权登录页,等待用户授权完成之后浏览器重定向到我们的callback URL最终请求user信息端点即可访问到刚刚登入的github用户信息,整个应用的构建是如此的简单,背后的原理是什么呢?接下来我们开始分析。
还是和以前一样,我们在配置文件中将security的日志级别设置为debug
logging:
level:
org.springframework.security: debug
重新启动应用之后,从控制台输出中我们可以看到与普通spring-security应用不同的地方在于整个过滤链多出了以下几个过滤器:
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
联想oauth2的授权码模式以及这两个过滤器的名字,熟悉spring-security的同学心中肯定已经有了一点想法了。对没错,spring-security对客户端模式的支持完全就是基于这两个过滤器来实现的。现在我们来回想以下授权码模式的执行流程
public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
......省略部分代码@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {try {
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);if (authorizationRequest != null) {this.sendRedirectForAuthorization(request, response, authorizationRequest);return;
}
} catch (Exception failed) {this.unsuccessfulRedirectForAuthorization(request, response, failed);return;
}
......省略部分代码
}
通过authorizationRequestResolver解析器解析请求,解析器的默认实现是DefaultOAuth2AuthorizationRequestResolver,核心解析方法如下:
@Overridepublic OAuth2AuthorizationRequest resolve(HttpServletRequest request) {String registrationId = this.resolveRegistrationId(request);String redirectUriAction = getAction(request, "login");return resolve(request, registrationId, redirectUriAction);
}private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction) {if (registrationId == null) {return null;
}
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);if (clientRegistration == null) {throw new IllegalArgumentException("Invalid Client Registration with Id: " + registrationId);
}
Map<String, Object> attributes = new HashMap<>();
attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
OAuth2AuthorizationRequest.Builder builder;if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
builder = OAuth2AuthorizationRequest.authorizationCode();
Map<String, Object> additionalParameters = new HashMap<>();if (!CollectionUtils.isEmpty(clientRegistration.getScopes()) &&
clientRegistration.getScopes().contains(OidcScopes.OPENID)) {
addNonceParameters(attributes, additionalParameters);
}if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) {
addPkceParameters(attributes, additionalParameters);
}
builder.additionalParameters(additionalParameters);
} else if (AuthorizationGrantType.IMPLICIT.equals(clientRegistration.getAuthorizationGrantType())) {
builder = OAuth2AuthorizationRequest.implicit();
} else {throw new IllegalArgumentException("Invalid Authorization Grant Type (" +
clientRegistration.getAuthorizationGrantType().getValue() +") for Client Registration with Id: " + clientRegistration.getRegistrationId());
}String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);
OAuth2AuthorizationRequest authorizationRequest = builder
.clientId(clientRegistration.getClientId())
.authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
.redirectUri(redirectUriStr)
.scopes(clientRegistration.getScopes())
.state(this.stateGenerator.generateKey())
.attributes(attributes)
.build();return authorizationRequest;
}
DefaultOAuth2AuthorizationRequestResolver判断请求是否是授权请求,最终返回一个OAuth2AuthorizationRequest对象给OAuth2AuthorizationRequestRedirectFilter,如果OAuth2AuthorizationRequest不为null的话,说明当前请求是一个授权请求,那么接下来就要拿着这个请求重定向到授权服务器的授权端点了,下面我们接着看OAuth2AuthorizationRequestRedirectFilter发送重定向的逻辑:
private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
OAuth2AuthorizationRequest authorizationRequest) throws IOException {if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
}this.authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri());
}
1. 如果当前是授权码类型的授权请求那么就需要将这个请求信息保存下来,因为接下来授权服务器回调我们需要用到这个授权请求的参数进行校验等操作(比对state),这里是通过authorizationRequestRepository保存授权请求的,默认的保存方式是通过HttpSessionOAuth2AuthorizationRequestRepository保存在httpsession中的,具体的保存逻辑很简单,这里就不细说了。
2. 保存完成之后就要开始重定向到授权服务端点了,这里默认的authorizationRedirectStrategy是DefaultRedirectStrategy,重定向的逻辑很简单,通过response.sendRedirect方法使前端页面重定向到指定的授权
public void sendRedirect(HttpServletRequest request, HttpServletResponse response,String url) throws IOException {String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);if (logger.isDebugEnabled()) {
logger.debug("Redirecting to '" + redirectUrl + "'");
}
response.sendRedirect(redirectUrl);
}
OAuth2AuthorizationRequestRedirectFilter处理逻辑讲完了,下面我们对它处理过程做一个总结
a. 通过内部的OAuth2AuthorizationRequestResolver解析当前的请求,返回一个OAuth2AuthorizationRequest对象,如果当前请求是授权端点请求,那么就会返回一个构造好的对象,包含我们的client_id、state、redirect_uri参数,如果对象为null的话,那么就说明当前请求不是授权端点请求。
注意如果OAuth2AuthorizationRequestResolver不为null的话,OAuth2AuthorizationRequestResolver内部会将其保存在httpsession中这样授权服务器在调用我们的回调地址时我们就能从httpsession中取出请求将state进行对比以防csrf攻击。
b. 如果第一步返回的OAuth2AuthorizationRequest对象不为null的话,接下来就会通过response.sendRedirect的方法将OAuth2AuthorizationRequest中的授权端点请求发送到前端的响应头中然后浏览器就会重定向到授权页面,等待用户授权。
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
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());
}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());
}String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replaceQuery(null)
.build()
.toUriString();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(
clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult =
(OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);
OAuth2AuthenticationToken oauth2Authentication = new OAuth2AuthenticationToken(
authenticationResult.getPrincipal(),
authenticationResult.getAuthorities(),
authenticationResult.getClientRegistration().getRegistrationId());
oauth2Authentication.setDetails(authenticationDetails);
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
authenticationResult.getClientRegistration(),
oauth2Authentication.getName(),
authenticationResult.getAccessToken(),
authenticationResult.getRefreshToken());this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);return oauth2Authentication;
}
}
OAuth2LoginAuthenticationFilter的作用很简单,就是响应授权服务器的回调地址,核心之处在于OAuth2LoginAuthenticationProvider对OAuth2LoginAuthenticationToken的认证,
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
...省略部分代码@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken authorizationCodeAuthentication =
(OAuth2LoginAuthenticationToken) authentication;if (authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest().getScopes().contains("openid")) {return null;
}
OAuth2AccessTokenResponse accessTokenResponse;try {
OAuth2AuthorizationExchangeValidator.validate(
authorizationCodeAuthentication.getAuthorizationExchange());
accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(new OAuth2AuthorizationCodeGrantRequest(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange()));
} catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
OAuth2AccessToken accessToken = accessTokenResponse.getAccessToken();
Map additionalParameters = accessTokenResponse.getAdditionalParameters();
OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
authorizationCodeAuthentication.getClientRegistration(), accessToken, additionalParameters));
Collection extends GrantedAuthority> mappedAuthorities =this.authoritiesMapper.mapAuthorities(oauth2User.getAuthorities());
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange(),
oauth2User,
mappedAuthorities,
accessToken,
accessTokenResponse.getRefreshToken());
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());return authenticationResult;
}
...省略部分代码
}
OAuth2LoginAuthenticationProvider的执行逻辑很简单,首先通过code获取access_token,然后通过access_token获取用户信息,这和标准的oauth2授权码模式一致。
自动配置
在spring指南的例子中,我们发现只是配置了一个简单oauth2Login()方法,一个完整的oauth2授权流程就构建好了,其实这完全归功于spring-boot的autoconfigure,我们找到spring-boot-autoconfigure.jar包中的security.oauth2.client.servlet包,可以发现spring-boot给我们提供了几个自动配置类:
OAuth2ClientAutoConfiguration
OAuth2ClientRegistrationRepositoryConfiguration
OAuth2WebSecurityConfiguration
其中OAuth2ClientAutoConfiguration导入了OAuth2ClientRegistrationRepositoryConfiguration和OAuth2WebSecurityConfiguration的配置
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OAuth2ClientProperties.class)
@Conditional(ClientsConfiguredCondition.class)class OAuth2ClientRegistrationRepositoryConfiguration {
@Bean
@ConditionalOnMissingBean(ClientRegistrationRepository.class)
InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {
List registrations = new ArrayList<>(
OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties).values());return new InMemoryClientRegistrationRepository(registrations);
}
}
OAuth2ClientRegistrationRepositoryConfiguration将我们在配置文件中注册的client构造成ClientRegistration然后保存到内存之中。这里有一个隐藏的CommonOAuth2Provider类,这是一个枚举类,里面事先定义好了几种常用的三方登录授权服务器的各种参数例如GOOGLE、GITHUB、FACEBOO、OKTA
public enum CommonOAuth2Provider {
GOOGLE {@Overridepublic Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");return builder;
}
},
GITHUB {@Overridepublic Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");return builder;
}
},
FACEBOOK {@Overridepublic Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.POST, DEFAULT_REDIRECT_URL);
builder.scope("public_profile", "email");
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");return builder;
}
},
OKTA {@Overridepublic Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Okta");return builder;
}
};private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";protected final ClientRegistration.Builder getBuilder(String registrationId,
ClientAuthenticationMethod method, String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUriTemplate(redirectUri);return builder;
}public abstract ClientRegistration.Builder getBuilder(String registrationId);
}
这就是为什么我们没有配置github授权端点确能够跳转授权页面的原因。
@Configuration(proxyBeanMethods = false)@ConditionalOnBean(ClientRegistrationRepository.class)
class OAuth2WebSecurityConfiguration {@Bean@ConditionalOnMissingBean
OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}
@Bean
@ConditionalOnMissingBeanOAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) {return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(WebSecurityConfigurerAdapter.class)static class OAuth2WebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {@Override
protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests((requests) -> requests.anyRequest().authenticated());http.oauth2Login(Customizer.withDefaults());http.oauth2Client();
}
}
}
参考:
集成GitHub和QQ社交登录
https://github.com/Allurx/spring-security-oauth2-demo/tree/master/spring-security-oauth2-client
spring-security-oauth更新路线
https://spring.io/blog/2019/11/14/spring-security-oauth-2-0-roadmap-update
spring-security对oauth2.0授权服务器的支持
https://github.com/spring-projects/spring-security/issues/6320
使用spring-boot和oauth2.0构建社交登陆
https://spring.io/guides/tutorials/spring-boot-oauth2/
- END -
最近整理一份面试资料《Java技术栈学习手册》,覆盖了Java技术、面试题精选、Spring全家桶、Nginx、SSM、微服务、数据库、数据结构、架构等等。获取方式:点“ 在看,关注公众号 Java后端 并回复 777 领取,更多内容陆续奉上。推荐阅读 1. 15000 字 30 图讲解 AQS2. 如何设计订单系统?不妨看看这篇文章3. 用命令行管理你的 GitHub 项目,不必再开网页4. 怎么去掉 IDEA 中 XML 显示的屎黄色
5. 程序员需知的 58 个网站
喜欢文章,点个在看
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!