写点什么

Spring Boot 接入 GitHub 第三方登录,只要两行配置!

作者:Java高工P7
  • 2021 年 11 月 11 日
  • 本文字数:8542 字

    阅读完需:约 28 分钟

在Github OAuth Apps中创建一个新的应用



这个应用相当于我们自己的应用(客户端),被注册在Github(授权服务器)中了,如果我们应用中的用户有github账号的话,则可以基于oauth2来登录我们的系统,替代原始的用户名密码方式。在官方指南的例子中,使用spring-security和oauth2进行社交登陆只需要在你的pom文件中加入以下几个依赖即可


<dependency>


<groupId>org.springframework.boot</groupId>


<artifactId>spring-boot-starter-oauth2-client</artifactId>


</dependency>


<dependency>


<groupId>org.springframework.boot</groupId>


<artifactId>spring-boot-starter-security</artifactId>


</dependency>


<dependency>


<groupId>org.springframework.boot</groupId>


<artifactId>spring-boot-starter-web</artifactId>


</dependency>


然后在配置文件中填上刚刚注册的应用的clientId和clientSecret


spring:


security:


oauth2:


client:


registration:


github:


clientId:?github-client-id


clientSecret:?github-client-secret


紧接着就像普通的spring-security应用一样,继承WebSecurityConfigurerAdapter,进行一些简单的配置即可


@SpringBootApplication


@RestController


public?class?SocialApplication?extends?WebSecurityConfigurerAdapter?{


//?...


@Override


protected?void?configure(HttpSecurity?http)?throws?Exception?{


//?@formatter:off


http


.authorizeRequests(a?->?a


.antMatchers("/",?"/error",?"/webjars/**").permitAll()


.anyRequest().authenticated()


)


.exceptionHandling(e?->?e


.authenticationEntryPoint(new?HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))


)


.oauth2Login();


//?@formatter:on


}


}


也就是说我们只需要添加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对客户端模式的支持完全就是基于这两个过滤器来实现的。现在我们来回想以下授权码模式的执行流程


  1. 用户在客户端页面点击三方应用登录按钮(客户端就是我们刚刚注册的github应用)

  2. 页面跳转到三方应用注册的授权方页面(授权服务器即github)

  3. 用户登入授权后,github调用我们应用的回调地址(我们刚刚注册github应用时填写的回调地址)

  4. 第三步的回调地址中github会将code参数放到url中,接下来我们的客户端就会在内部拿这个code再次去调用github的access_token地址获取令牌


上面就是标准的authorization_code授权模式,OAuth2AuthorizationRequestRedirectFilter的作用就是上面步骤中的1.2步的合体,当用户点击页面的github授权url之后,OAuth2AuthorizationRequestRedirectFilter匹配这个请求,接着它会将我们配置文件中的clientId、scope以及构造一个state参数(防止csrf攻击)拼接成一个url重定向到github的授权url,OAuth2LoginAuthenticationFilter的作用则是上面3.4步骤的合体,当用户在github的授权页面授权之后github调用回调地址,OAuth2LoginAuthenticationFilter匹配这个回调地址,解析回调地址后的code与state参数进行验证之后内部拿着这个code远程调用github的access_token地址,拿到access_token之后通过OAuth2UserService获取相应的用户信息(内部是拿access_token远程调用github的用户信息端点)最后将用户信息构造成Authentication被SecurityContextPersistenceFilter过滤器保存到HttpSession中。下面我们就来看一下这两个过滤器内部执行的原理


OAuth2AuthorizationRequestRedirectFilter




public?class?OAuth2AuthorizationRequestRedirectFilter?extends?OncePerRequestFilter?{


......省略部分代码


@Override


protected?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,核心解析方法如下


//?第一步解析


@Override


public?OAuth2AuthorizationRequest?resolve(HttpServletRequest?request)?{


//?通过内部的 authorizationRequestMatcher 来解析当前请求中的 registrationId


//?也就是/oauth2/authorization/github 中的 github


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;


}


//?根据传入的 registrationId 找到注册的应用信息


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;


//?根据不同的 AuthorizationGrantType 构造不同的 builder


if?(AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType()))?{


builder?=?OAuth2AuthorizationRequest.authorizationCode();


Map<String,?Object>?additionalParameters?=?new?HashMap<>();


if?(!CollectionU


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


tils.isEmpty(clientRegistration.getScopes())?&&


clientRegistration.getScopes().contains(OidcScopes.OPENID))?{


//?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.


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 值


.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 处理逻辑讲完了,下面我们对它处理过程做一个总结


  1. 通过内部的OAuth2AuthorizationRequestResolver解析当前的请求,返回一个OAuth2AuthorizationRequest对象,如果当前请求是授权端点请求,那么就会返回一个构造好的对象,包含我们的client_id、state、redirect_uri参数,如果对象为null的话,那么就说明当前请求不是授权端点请求。注意如果OAuth2AuthorizationRequestResolver不为null的话,OAuth2AuthorizationRequestResolver内部会将其保存在httpsession中这样授权服务器在调用我们的回调地址时我们就能从httpsession中取出请求将state进行对比以防csrf攻击。

  2. 如果第一步返回的OAuth2AuthorizationRequest对象不为null的话,接下来就会通过response.sendRedirect的方法将OAuth2AuthorizationRequest中的授权端点请求发送到前端的响应头中然后浏览器就会重定向到授权页面,等待用户授权。


OAuth2LoginAuthenticationFilter




public?class?OAuth2LoginAuthenticationFilter?extends?AbstractAuthenticationProcessingFilter?{


@Override


public?Authentication?attemptAuthentication(HttpServletRequest?request,?HttpServletResponse?response)


throws?AuthenticationException?{


MultiValueMap<String,?String>?params?=?OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());


//?如果请求参数中没有 state 和 code 参数,说明当前请求是一个非法请求


if?(!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params))?{


OAuth2Error?oauth2Error?=?new?OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);


throw?new?OAuth2AuthenticationException(oauth2Error,?oauth2Error.toString());


}


//?从 httpsession 中取出 OAuth2AuthorizationRequestRedirectFilter 中保存的授权请求,


//?如果找不到的话说明当前请求是非法请求


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 委托给 AuthenticationManager


//?选择合适的 AuthenticationProvider 来对其进行认证,这里的 AuthenticationProvider 是


//?OAuth2LoginAuthenticationProvider


OAuth2LoginAuthenticationToken?authenticationResult?=


(OAuth2LoginAuthenticationToken)?this.getAuthenticationManager().authenticate(authenticationRequest);


//?将最终的认证信息封装成 OAuth2AuthenticationToken


OAuth2AuthenticationToken?oauth2Authentication?=?new?OAuth2AuthenticationToken(


authenticationResult.getPrincipal(),


authenticationResult.getAuthorities(),


authenticationResult.getClientRegistration().getRegistrationId());


oauth2Authentication.setDetails(authenticationDetails);


//?构造 OAuth2AuthorizedClient,将所有经过授权的客户端信息保存起来,默认是通过


//?AuthenticatedPrincipalOAuth2AuthorizedClientRepository 来保存的,


//?然后就能通过其来获取之前所有已授权的 client?暂时不能确定其合适的用途


OAuth2AuthorizedClient?authorizedClient?=?new?OAuth2AuthorizedClient(


authenticationResult.getClientRegistration(),


oauth2Authentication.getName(),


authenticationResult.getAccessToken(),


authenticationResult.getRefreshToken());


this.authorizedClientRepository.saveAuthorizedClient(authorizedClient,?oauth2Authentication,?request,?response);


return?oauth2Authentication;


}


}


OAuth2LoginAuthenticationFilter的作用很简单,就是响应授权服务器的回调地址,核心之处在于OAuth2LoginAuthenticationProvider对OAuth2LoginAuthenticationToken的认证。


OAuth2LoginAuthenticationProvider




public?class?OAuth2LoginAuthenticationProvider?implements?AuthenticationProvider?{


...省略部分代码


@Override


public?Authentication?authenticate(Authentication?authentication)?throws?AuthenticationException?{


OAuth2LoginAuthenticationToken?authorizationCodeAuthentication?=


(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.


if?(authorizationCodeAuthentication.getAuthorizationExchange()


.getAuthorizationRequest().getScopes().contains("openid"))?{


//?This?is?an?OpenID?Connect?Authentication?Request?so?return?null


//?and?let?OidcAuthorizationCodeAuthenticationProvider?handle?it?instead


return?null;


}


OAuth2AccessTokenResponse?accessTokenResponse;


try?{


OAuth2AuthorizationExchangeValidator.validate(


authorizationCodeAuthentication.getAuthorizationExchange());


//?远程调用授权服务器的 access_token 端点获取令牌


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<String,?Object>?additionalParameters?=?accessTokenResponse.getAdditionalParameters();


//?通过 userService 使用上一步拿到的 accessToken 远程调用授权服务器的用户信息


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的配置


OAuth2ClientRegistrationRepositoryConfiguration




@Configuration(proxyBeanMethods?=?false)


@EnableConfigurationProperties(OAuth2ClientProperties.class)


@Conditional(ClientsConfiguredCondition.class)


class?OAuth2ClientRegistrationRepositoryConfiguration?{


@Bean


@ConditionalOnMissingBean(ClientRegistrationRepository.class)


InMemoryClientRegistrationRepository?clientRegistrationRepository(OAuth2ClientProperties?properties)?{


List<ClientRegistration>?registrations?=?new?ArrayList<>(


OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties).values());


return?new?InMemoryClientRegistrationRepository(registrations);


}


}


OAuth2ClientRegistrationRepositoryConfiguration将我们在配置文件中注册的client构造成ClientRegistration然后保存到内存之中。这里有一个隐藏的CommonOAuth2Provider类,这是一个枚举类,里面事先定义好了几种常用的三方登录授权服务器的各种参数例如GOOGLE、GITHUB、FACEBOO、OKTA


CommonOAuth2Provider




用户头像

Java高工P7

关注

还未添加个人签名 2021.11.08 加入

还未添加个人简介

评论

发布
暂无评论
Spring Boot 接入 GitHub 第三方登录,只要两行配置!