GitHub 授权登录
我们通过一个 GitHub 授权登录来体验下 OAuth2 认证流程。
准备工作
首先我们需要将第三方应用的信息注册到 GitHub 上,打开 https://github.com/settings/developers 链接,单击 New OAuth App,注册一个新的应用,如图 15-6 所示。

在打开的页面中,填入应用的基本信息:
-
Application name:应用名称。
-
Homepage URL:项目主页面。
-
Application description:项目描述信息(可选)
-
Authorization callback URL:认证成功后的回调页面,默认的回调 URI 地址模板为 {baseUrl}/login/oauth2/code/{registrationId},其中 registrationId 是 ClientRegistration 的唯一标识符,如果这里使用了默认的回调地址,则在接下来的 SpringBoot 项目中就不必提供回调接口了。
如图 15-7 所示,信息填完之后,单击下方的 Register application 按钮完成注册。

注册成功后,会获取到一个 Client ID 和一个 Client Secret,如图 15-8 所示。

保存好 Client ID 和 Client Secret,在接下来的项目中我们会用到这两个参数。
准备工作就算完成了。
项目开发
创建一个 Spring Boot 项目,引入 Web、Spring Security 以及 OAuth2 Client 依赖,如图 15-9 所示。

项目创建成功后,在 application.properties 文件中配置刚刚申请到的 Client ID 和 Client Secret,代码如下:
spring.security.oauth2.client.registration.github.client-id=aa9e79846df9cbc6201f
spring.security.oauth2.client.registration.github.client-secret=c324b93443594fe84d106bb32c904799e1839e6a
接下来提供一测试接口:
@RestController
public class HelloController {
@GetMapping("/hello")
public DefaultOAuth2User hello() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return ((DefaultOAuth2User) authentication.getPrincipal());
}
}
在测试接口中获取当前登录用户信息。注意,此时的用户对象是 DefaultOAuth2User,将获取到的当前登录用户对象返回。
最后我们再来简单配置一下 Spring Security:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login();
}
}
这段配置也非常简单,所有接口都需要认证后才能访问,同时调用 oauth2Login() 方法开启 OAuth2 登录。
现在我们的项目就开发完成了。
测试
启动 Spring Boot 项目,访问 http://localhost:8080/hello 地址,由于该地址需要认证后才能访问,此时服务端会返回 302,要求浏览器重定向到 http:/localhost:8080/oauth2/authorization/github 页面。不同的第三方登录只是地址的最后一项不同,如果是 Google 第三方登录,则登录页面是 /oauth2/authorization/google。
当浏览器去请求该页面时,服务端检测到这是一个授权请求,于是再次返回 302,要求浏览器重定向到 GitHub 授权页面 https://github.com/login/oauth/authorize?response_type=code&client_id=aa9e79846df9cbc6201f&scope=read:user&state=vY8CJuRg2WiROVyo4mBZUn_1ksl6ieBjkmOQyGYA0A%3D&redirect_uri=http://localhost:8080/login/oauth2/code/github ,这个 URL 地址的参数比较多,但都是我们前面 15.2.1 小节中介绍的授权码模式中的参数,因此这单不做过多解释。
接下来 GitHub 的授权服务器还会再次要求重定向,但是这就和我们这里的 OAuth2 没有关系了,最终来到 GitHub 认证页面,如图 15-10 所示。
输入用户名/密码,完成认证后,GitHub 授权服务器又会要求浏览器重定向到提前配置好的 Authorization callback URL 地址上,同时还会携带一个授权码参数 http://localhost:8080/login/oauth2/code/github?code=b15a5ed5198650e47e6a&state=cRYcu1Xg3E0sdlJAlbbi1CNeJT5-PdBOYVEaUkxcF8g%3D 。
当浏器请求该地址时,客户端会根据这里的授权码 code,向 GitHub 授权服务器的 https://github.com/login/oauth/access_token 接口去请求 Access Token,拿到 Access Token 之后,再向 https://api.github.com/user 地址发送请求,获取用户信息。
前面几步都是浏览器中可见的,最后获取令牌 Access Token 和获取用户信息的过程,是在后端完成的,浏览器将不可见。
所有工作都完成后,最终会自动跳转回 http://localhost:8080/hello 页面,在该页面可以看到用户登录成功后的信息,如图 15-11 所示。

原理分析
可以看到,接入 GitHub 第三方登录整个过程非常顺畅,开发者几乎不需要做什么事情,GitHub 上注册应用,项目中配置一下 Client ID 和 Client Secret,然后再开启一下 OAuth2 登录就可以了。
那么 Spring Security 如何得知 GitHub 授权地址、用户接口、令牌接口等信息?
由于用户接口、令牌接口、授权地址等信息一般不会轻易变化,所以 Spring Security 将一些常用的第三方登录如 Google、GitHub、Facebook、Okta 的信息收集起来,保存在一个枚举类 CommonOAuth2Provider 中,当我们在 application.properties 中配置 GitHub 时,就会自动选择枚举类中的 GITHUB。我们来看一下 CommonOAuth2Provider 中关于 GITHUB 信息的定义:
GITHUB {
@Override
public 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;
}
},
可以看到,需要用到的地址都提前定义好了。
当我们开启 OAuth2 自动登录之后,在 Spring Security 过滤器链中多了两个过滤器:
-
OAuth2AuthorizationRequestRedirectFilter
-
OAuth2LoginAuthenticationFilter
回顾一下 15.2.1 小节所讲的授权码模式工作流程,当用户在没有登录时就去访问 http://localhost:8080/hello 地址,会被自动导入到 GitHub 授权页面,这个过程是由 OAuth2AuthorizationRequestRedirectFilter 过滤器完成的。
接下来用户进行 GitHub 登录,登录成功后,GitHub 授权服务器会调用回调地址,同时返回一个授权码,客户端再根据授权码去 GitHub 授权服务器上获取 Access Token,有了 Access Token 就可以获取用户信息了,这个过程是由 OAuth2LoginAuthenticationFilter 过滤器来完成的。
接下来我们对这里涉及的几个关键类进行简单分析。
OAuth2ClientRegistrationRepositoryConfiguration
OAuth2ClientRegistrationRepositoryConfiguration 是一个配置类,当项目启动时,该类会自动加载,并向 Spring 容器中注册一个 InMemoryClientRegistrationRepository 实例,该实例保存了客户端注册表信息,代码如下:
@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);
}
}
可以看到,clientRegistrationRepository 方法的参数实际上就是我们在 application.properties 中配置的 GitHub 的 Client ID 和 Client Secret。接下来调用 getClientRegistrations 方法,会将 CommonOAuth2Provider 枚举类中预设的 GitHub 信息和用户配置的 GitHub 信息合并然后返回。如果 application.properties 中只是配置了 GitHub 信息,则这里的 registrations 集合中就只有一项;如果 application.properties 中还配置了 Facebook、Google 等信息,则 registrations 集合中就包含多项。
OAuth2AuthorizationRequestRedirectFilter
OAuth2AuthorizationRequestRedirectFilter 过滤器主要是判断当前请求是否是授权请求,如果是授权请求,则进行重定向到 GitHub 授权页面,否则执行下一个过滤器。
我们来看一下该过滤器的 doFilterInternal 方法:
@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) {
// 省略其他
}
try {
filterChain.doFilter(request, response);
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
// 省略其它
}
}
首先调用 authorizationRequestResolver.resolve 方法将当前请求解析为一个 OAuth2AuthorizationRequest 对象:如果当前请求是授权请求(如 http://localhost:8080/oauth2/authorization/github ),则根据 InMemoryClientRegistrationRepository 中保存的客户端注册表信息,构造一个 OAuth2AuthorizationRequest 对象并返回;如果当前请求不是授权请求,而是一个普通请求,则这里返回的 OAuth2AuthorizationRequest 对象为 null。
如果获取到的 authorizationRequest 对象不为 null,即当前请求是授权请求,则调用 sendRedirectForAuthorization 方法进行重定向,重定向的地址就是 GitHub 的授权地址(即枚举类 CommonOAuth2Provider 中 authorizationUri 方法所配置的地址)。当然这里的地址会在该地址上再自动加上 response_type、client_id、scope、state 以及 redirect_uri 参数(这些参数都可以从枚举类中获取)。另外,在重定向之前,还会将当前授权请求保存到一个 Map 集合中,并将 Map 集合保存到 HttpSession 中,以备后续使用。
OAuth2LoginAuthenticationFilter
通过前面的讲解,可能有读者会疑惑,GitHub 授权服务器登录成功后的回调地址是 http://localhost:8080/login/oauth2/code/github ,但是我们的项目中并没有定义这样一个接口,为什么还能调用成功呢?这就是 OAuth2LoginAuthenticationFilter 过滤器所起的作用了!
OAuth2LoginAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter,它目前的角色相当于我们之前所讲的 UsernamePasswordAuthenticationFilter 过滤器的角色。在 AbstractAuthenticationProcessingFilter 过滤器中会拦截下认证请求进行处理。我们来看一下 AbstractAuthenticationProcessingFilter#doFilter 方法:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
// 省略
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
// 省略
}
如果使用了 OAuth2 登录,这里的逻辑就是判断当前请求接口是否是 /login/oauth2/code/* 格式,如果是,说明这是一个认证请求,将该请求拦截下来交给 OAuth2LoginAuthenticationFilter#attemptAuthentication 方法去处理;如果不是,则继续执行下一个过滤器。
我们来看一下 OAuth2LoginAuthenticationFilter#attemptAuthentication 方法:
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // 1 MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap()); if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) { // 省略 } // 2 OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response); if (authorizationRequest == null) { // 省略 } // 3 String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID); ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); if (clientRegistration == null) { // 省略 } // 4 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; }
-
注释 1 是对请求参数进行校验,请求必须包含授权码 code 和 state 两个参数,否则会抛出异常。
-
注释 2 是从 HttpSession 中获取在 OAuth2AuthorizationRequestRedirectFilter 过滤器中保存的授权请求,如果获取到的对象为 null,则抛出异常。
-
注释 3 是检查当前注册应用中是否有授权请求时的应用,如果没有,则抛出异常。
-
注释 4 是构造一个未经认证的 OAuth2LoginAuthenticationToken 对象,并调用 authenticate 方法进行认证。认证成功后,最终封装成一个 OAuth2AuthenticationToken 对象并返回。这一块读者可以回顾本书 3.1.4 小节的分析,流程者都是一样的,只是实现细节有所差异而已。需要注意的是,这里认证时调用的 AuthenticationProvider 是 OAuth2LoginAuthenticationProvider。
OAuth2LoginAuthenticationProvider
OAuth2LoginAuthenticationProvider 负责最终的校验工作,作用类似 3.1.2 小节所讲的 DaoAuthenticationProvider,我们来看一下它的 authenticate 方法:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken =
(OAuth2LoginAuthenticationToken) authentication;
// 1
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;
}
// 2
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
try {
authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
.authenticate(new OAuth2AuthorizationCodeAuthenticationToken(
loginAuthenticationToken.getClientRegistration(),
loginAuthenticationToken.getAuthorizationExchange()));
} catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
// 3
OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
// 4
OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
Collection<? extends GrantedAuthority> mappedAuthorities =
this.authoritiesMapper.mapAuthorities(oauth2User.getAuthorities());
// 5
OAuth2L oginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
loginAuthenticationToken.getClientRegistration(),
loginAuthenticationToken.getAuthorizationExchange(),
oauth2User,
mappedAuthorities,
accessToken,
authorizationCodeAuthenticationToken.getRefreshToken());
authenticationResult.setDetails(loginAuthenticationToken.getDetails());
return authenticationResult;
}
-
注释 1 是判断是否为 OpenID Connect 认证,如果是,则返回 null,请求交给 OidcAuthorizationCodeAuthenticationProvider 去处理。
-
注释 2 是根据授权码 code 去请求 https://github.com/login/oauth/access_token 接口获取 Access Token,这一步调用到了 OAuth2AuthorizationCodeAuthenticationProvider#authenticate 方法,并在该方法中调用 DefaultAuthorizationCodeTokenResponseClient#getTokenResponse 方法发起网络请求,底层使用的网络请求工具是 RestTemplate。
-
注释 3 是根据上一步的结果,提取出 accessToken 对象。
-
注释 4 是根据获取到的 accessToken 对象,向 https://api.github.com/user 地址发起请求,获取用户信息,并最终封装为一个 OAuth2User 对象。
-
注释 5 是构造一个 OAuth2LoginAuthenticationToken 对象并返回。
我们在 GitHub 上配置的 http://localhost:8080/login/oauth2/code/github 地址其实类似于登录请求,当 GiHub 授权服务器重定尚到该地址时,重定向请求携带了授权码参数,客户端根据授权码获取 Access Token,再根据 Access Token 加载到用户对象,最终构建 OAuth2LoginAuthenticationToken 并返回。
至此,整个 GitHub 授权登录就分析完了,我们再结合 15.2.1 小节中介绍的授权码模式的工作流程,应该就很好理解了。
自定义配置
自定义 ClientRegistrationRepository
完全使用自动化配置虽然方便,但是灵活性却降低了。假如我们在 GitHub 上注册 App 时,填写的回调地址不是 http://localhost:8080/login/oauth2/code/github,而是其他地址,此时就需要,我们手动配置了。
举个简单例子,假设我们在 GitHub 上注册 App 时填写的回调地址是 http://localhost:8080/authorization_code,那么可以通过如下方式配置客户端。
在 application.properties 文件中修改重定向地址:
spring.security.oauth2.client.registration.github.client-id=aa9e79846df9cbc6201f
spring.security.oauth2.client.registration.github.client-secret=c324b93443594fe84d106bb32c904799e1839e6a
spring.security.oauth2.client.registration.github.redirect-uri=http://localhost:8080/authorization_code
然后修改认证请求处理地址:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginProcessingUrl("/authorization_code");
}
}
前面我们已经分析过,GitHub 配置的回调地址相当于登录请求链接,默认的 loginProcessingUrl 是 /login/oauth2/code/github,所以默认情况下,当重定向到 http://localhost:8o80/authorization_code 地址时,该请求会被当成一个普通请求,无法在 OAuth2LoginAuthenticationFilter 过滤器中进行登录处理。在只有修改 loginProcessingUrl 地址才能确保当重定向到 http://localhost:8080/authorization_code 地址时,该请求会在 AbstractAuthenticationProcessingFilter#doFilter 方法中被认定为一个登录请求,进而将请求交给 OAuth2LoginAuthenticationFilter#attemptAuthentication 方法去处理,以完成登录操作。
这是一种自定义配置的方式。
我们也可以使用 Java 代码,完成更加丰富、更加灵活的配置。
使用 Java 代码配置时,可以删除 application.properties 中的所有配置,然后修改配置类,代码如下:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.userInfoEndpoint().customUserType(GitHubOAuth2User.class,"github")
.and()
.loginProcessingUrl("/authorization_code");
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(githubClientRegistration());
}
private ClientRegistration githubClientRegistration() {
return ClientRegistration.withRegistrationId("github")
.clientId("aa9e79846df9cbc6201f")
.clientSecret("c324b93443594fe84d106bb32c904799e1839e6a")
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
.userNameAttributeName("id")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUriTemplate("http://localhost:8080/authorization_code")
.scope("read:user")
.authorizationUri("https://github.com/login/oauth/authorize")
.tokenUri("https://github.com/login/oauth/access_token")
.userInfoUri("https://api.github.com/user")
.clientName("GitHub")
.build();
}
}
我们只需要向 Spring 容器中注册一个 ClientRegistrationRepository 实例,然后在该实例中提供 GitHub 的配置信息即可,此时 OAuth2ClientRegistrationRepositoryConfiguration 配置类自动配置的 ClientRegistrationRepository 实例就会失效。
这种配置方式非常直观也非常灵活,所有需要的配置信息现在都摆出来了,需要修改哪个直接修改即可。
自定义用户
默认情况下,GitHub 返回的用户信息被包装成一个 DefaultOAuth2User 对象,但是 DefaultOAuth2User 是通过一个 Map 集合来保存 GitHub 用户信息,这样解析起来并个方便,因此我们也可以自定义用户对象。
自定义用户对象实现 OAuth2User 接口即可,代码如下:
public class GitHubOAuth2User implements OAuth2User {
private List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList("ROLE_USER");
private Map<String, Object> attributes;
private String id;
private String name;
private String login;
private String email;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public Map<String, Object> getAttributes() {
if (this.attributes == null) {
this.attributes = new HashMap<>();
this.attributes.put("id", this.getId());
this.attributes.put("name", this.getName());
this.attributes.put("login", this.getLogin());
this.attributes.put("email", this.getEmail());
}
return attributes;
}
public String getId() {
return this.id;
}
public void setId(String id) {
this.id = id;
}
@Override
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getLogin() {
return this.login;
}
public void setLogin(String login) {
this.login = login;
}
public String getEmail() {
return this.email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public String toString() {
return "GitHubOAuth2User{" +
"authorities=" + authorities +
", attributes=" + getAttributes() +
", id='" + id + '\'' +
", name='" + name + '\'' +
", login='" + login + '\'' +
", email='" + email + '\'' +
'}';
}
}
这里定义的 id、name、login 以及 email 属性,都是 GitHub 返回的用户信息中所包含的,如果还想唤射其他用户信息,则继续定义相应的属性即可。最后在配置类中使用该首定义用户对象:
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.userInfoEndpoint().customUserType(GitHubOAuth2User.class,"github")
.and()
.loginProcessingUrl("/authorization_code");
}
配置完成后,在通过 Access Token 去加载用户信息这一环节中,将不再使用 DefaultOAuth2UserService 类去完成加载,而是使用 CustomUserTypesOAuth2UserService,该类支持自定义用户对象。
配置完成后,重启项目完成认证,此时再从 SecurityContextHolder 中提取出来的用户对象就不再是 DefaultOAuth2User,而是 GitHubOAuth2User 了。