1. 首页
  2. >
  3. 架构设计

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

OAuth 2.0 允许第三方应用程序访问受限的HTTP资源的授权协议,像平常大家使用 Github 、 Google 账号来登陆其他系统时使用的就是 OAuth 2.0 授权框架,下图就是使用 Github 账号登陆 Coding 系统的授权页面图:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

类似使用 OAuth 2.0 授权的还有很多,本文将介绍 OAuth 2.0 相关的概念如:角色、授权类型等知识,以下是我整理一张 OAuth 2.0 授权的脑头,希望对大家了解 OAuth 2.0 授权协议有帮助。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

文章将以脑图中的内容展开 OAuth 2.0 协议同时除了 OAuth 2.0 外,还会配合 Spring Security OAuth2 来搭建 OAuth2客户端 ,这也是学习 OAuth 2.0 的目的,直接应用到实际项目中,加深对 OAuth 2.0 和 Spring Security 的理解。

OAuth 2.0 角色

OAuth 2.0 中有四种类型的角色分别为: 资源Owner 、 授权服务 、 客户端 、 资源服务 ,这四个角色负责不同的工作,为了方便理解先给出一张大概的流程图,细节部分后面再分别展开:

OAuth 2.0 大概授权流程

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

资源 Owner

资源 Owner可以理解为一个用户,如之前提到使用 Github 登陆 Coding 中的例子中,用户使用GitHub账号登陆Coding,Coding就需要知道用户在GitHub系统中的的头像、用户名、email等信息,这些账户信息都是属于用户的这样就不难理解 资源 Owner 了。在Coding请求从GitHub中获取想要的用户信息时也是没那容易的,GitHub为了安全起见,至少要通过用户(资源 Owner)的同意才行。

资源服务器

明白 资源 Owner 后,相信你已经知道什么是 资源服务器 ,在这个例子中用户账号的信息都存放在GitHub的服务器中,所以这里的资源服务器就是GitHub服务器。GitHub服务器负责保存、保护用户的资源,任何其他第三方系统想到使用这些信息的系统都需要经过 资源 Owner 授权,同时依照 OAuth 2.0 授权流程进行交互。

客户端

知道 资源 Owner 和 资源服务器 后,OAuth中的客户端角色也相对容易理解了,简单的说 客户端就是想要获取资源的系统 ,如例子中的使用GitHub登陆Coding时,Coding就是OAuth中的客户端。客户端主要负责发起授权请求、获取AccessToken、获取用户资源。

授权服务器

有了 资源 Owner 、 资源服务器 、 客户端 还不能完成OAuth授权的,还需要有 授权服务器 。在OAuth中授权服务器除了负责与用户(资源 Owner)、客房端(Coding)交互外,还要生成AccessToken、验证AccessToken等功能,它是OAuth授权中的非常重要的一环,在例子中授权服务器就是GitHub的服务器。

小结

OAuth中: 资源Owner 、 授权服务 、 客户端 、 资源服务 有四个角色在使用GitHub登陆Coding的例子中分别表示:

  • 资源Owner:GitHub用户
  • 授权服务:GitHub服务器
  • 客户端:Coding系统
  • 资源服务:GitHub服务器

其中授权服务服务器、资源服务器可以单独搭建(鬼知道GitHub怎么搭建的)。在微服务器架构中可单独弄一个授权服务,资源服务服务可以多个如:用户资源、仓库资源等,可根据需求自由分服务。

OAuth2 Endpoint

OAuth2有三个重要的Endpoint其中 授权 Endpoint 、 Token Endpoint 结点在授权服务器中,还有一个可选的 重定向 Endpoint 在客户端中。

授权 Endpoint
重定向 Endpoint

授权类型

通过四个OAuth角色,应该对OAuth协议有一个大概的认识,不过可能还是一头雾水不知道OAuth中的角色是如何交互的,没关系继续往下看一下 授权类型 就知道OAuth中的角色是如何完成自己的职责,进一步对OAuth的理解。在OAuth中定义了四种 授权类型 ,分别为:

  • 授权码授权
  • 客房端凭证授权
  • 资源Owner的密码授权
  • 隐式的授权

不同的 授权类型 可以使用在不同的场景中。

授权码授权

这种形式就是我们常见的授权形式(如使用GitHub账号登陆Coding),在整个授权流程中会有 资源Owner 、 授权服务器 、 客户端 三个OAuth角色参与,之所以叫做 授权码授权 是因为在交互流程中授权服务器会给客房端发放一个 code ,随后客房端拿着授权服务器发放的code继续进行授权如:请求授权服务器发放AccessToken。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

为方便理解再将上图的内容带进真实的场景中,用文字表述一下整个流程:

  • A.1、用户访问Coding登陆页( https://coding.net/login ),点击Github登陆按钮;
  • A.2、Coding服务器将浏览器重定向到Github的授权页( https://github.com/login/oauth/authorize?client_id=a5ce5a6c7e8c39567ca0&scope=user:email&redirect_uri=https://coding.net/api/oauth/github/callback&response_type=code ),同时URL带上 client_id 和 redirect_uri 参数;
  • B.1、用户输入用户名、密码登陆Github;
  • B.2、用户点击 授权按钮 ,同意授权;
  • C.1、Github授权服务器返回 code ;
  • C.2、Github通过将浏览器重定向到 A.2 步骤中传递的 redirect_uri 地址(https://coding.net/api/oauth/github/callback&response_type=code);
  • D、Coding拿到code后,调用Github授权服务器API获取AccessToken,由于这一步是在Coding服务器后台做的浏览器中捕获不到,基本就是使用 code 访问github的access_token节点获取AccessToken;

以上是大致的 授权码授权 流程,大部分是客户端与授权服务器的交互,整个过程中有几个参数说明如下:

  • client_id:在Github中注册的Appid,用于标记客户端
  • redirect_uri:可以理解一个 callback ,授权服务器验证完客户端与用户名等信息后将浏览器重定向到此地址并带上 code 参数
  • code:由授权服务器返回的一个凭证,用于获取AccessToken
  • state:由客户端传递给授权服务器,授权服务器一般到调用 redirect_uri 时原样返回

授权码授权请求

在使用授权码授权的模式中,作为客户端请求授权的的时候都需要按规范请求,以下是使用授权码授权发起授权时所需要的参数 :

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

如使用Github登陆Coding例子中的 https://github.com/login/oauth/authorize?client_id=a5ce5a6c7e8c39567ca0&scope=user:email&redirect_uri=https://coding.net/api/oauth/github/callback&response_type=code 授权请求URL,就有 client_id 、 redirect_uri 参数,至于为啥没有 response_type 在下猜想是因为Github给省了吧。

授权码授权响应

如果用户同意授权,那授权服务器也会返回标准的OAuth授权响应:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

如Coding登陆中的 https://coding.net/api/oauth/github/callback&response_type=code ,用户同意授权后Github授权服务器回调Coding的回调地址,同时返回 code 、 state 参数。

客户端凭证授权

客房端凭证授权 授权的过程中只会涉及客户端与授权服务器交互,相比较其他三种授权类型是比较简单的。一般这种授权模式是用于服务之间授权,如在AWS中两台服务器分别为应用服务器(A)和数据服务器(B),A 服务器需要访问 B 服务器就需要通过授权服务器授权,然后才能去访问 B 服务器获取数据。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

简单二步就可以完成 客房端凭证授权 啦,不过在使用 客房端凭证授权 时客户端是直接访问的授权服务器中获取AccessToken接口。

客户端凭证授权请求

客房端凭证授权中客户端会直接发起获取AccessToken请求授权服务器的 AccessToken Endpoint,请求参数如下:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

注意:在OAuth中 AccessToken Endpoint是使用HTTP Basic认证,在请求时还需要携带 Authorization 请求头,如使用 postman 测试请求时:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

其中的 username 和 password 参数对于OAuth协议中的 client_id 和 client_secret , client_id 和 client_secret 都是由授权服务器生成的。

客户端凭证授权响应

授权服务器验证完 client_id 和 client_secret 后返回token:

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"example_parameter":"example_value"
}

用户凭证授权

用户凭证授权 与 客户端凭证授权 类似,不同的地方是进行授权时要提供用户名和用户的密码。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

基本流程如下:

  • A、客户端首先需要知道用户的凭证
  • B、使用用户凭证获取AccessToken
  • C、授权服务器验证客户端与用户凭证,返回AccessToken

用户凭证授权请求

用户凭证授权请求参数要比客户端凭证授权多 username 和 pwssword 参数:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

注意: 获取Token时使用HTTP Basic认证,与客户端凭证授权一样。

用户凭证授权响应

用户凭证授权响应与客户端凭证授权差不多:

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}

隐式授权

隐式授权用于获取AccessToken,但是获取的方式与 用户凭证授权 和 客户端授权 不同的是,它是在访问 授权Endpoint 的时候就会获取AccessToken而不是访问 Token Endpoing ,而且AccessToken的会作为 redirect_uri 的Segment返回。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

  • A.1、A.2、浏览器访问支持隐式授权的服务器的授权Endpoint;
  • B.1、用户输入账号密码;
  • B.2、用户点击 授权按钮 ,同意授权;
  • C、授权服务器使用 redirect_uri 返回AccessToken;
  • D、授权服务器将浏览器重定向到 redirect_uri ,并携带AccessToken如: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA&state=xyz&token_type=example&expires_in=3600 ;
  • D、 redirect_uri 的地址是指向一个 Web资源客户端
  • E、 Web资源客户端 返回一段脚本
  • F、浏览器执行脚本
  • D、客户端获得AccessToken

隐式授权不太好理解,但是仔细比较 客户端凭证授权 和 用户凭证授权 会发现 隐式授权 不需要知道 用户凭证 或 客户端凭证 ,这样做相对更安全。

隐式授权请求

再使用 隐式授权 时,所需要请求参数如下:

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

隐式授权响应

隐式授权响应参数是通过 redirect_uri 回调返回的,如 http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA &state=xyz&token_type=example&expires_in=3600 就是隐式授权响应参数,其中需要注意的是响应的参数是使用Segment的形式的,而不是普通的URL参数。

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

OAuth2 客户端

前面提到过OAuth协议中有四个角色,这一节使用Spring Boot实现一个登陆 GitHub 的 OAuthClient ,要使用OAuth2协议登陆GitHub首先要云GitHub里面申请:

申请 OAuth App

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

填写必需的信息

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

上图中的 Authorization callback URL 就是 redirect_uri 用户同意授权后GitHub会将浏览器重定向到该地址,因此先要在本地的OAuth客户端服务中添加一个接口响应GitHub的重定向请求。

配置OAuthClient

熟悉OAuth2协议后,我们在使用 Spring Security OAuth2 配置一个GitHub授权客户端,使用 认证码 授权流程(可以先去看一遍认证码授权流程图),示例工程依赖:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

Spring Security OAuth2 默认集成了Github、Goolge等常用的授权服务器,因为这些常用的授权服务的配置信息都是公开的,Spring Security OAuth2 已经帮我们配置了,开发都只需要指定必需的信息就行如:clientId、clientSecret。

Spring Security OAuth2 使用 Registration 作为客户端的的配置实体:

public static class Registration {
//授权服务器提供者名称
private String provider;
//客户端id
private String clientId;
//客户端凭证
private String clientSecret;
....

下面是之前注册好的 GitHub OAuth App 的信息:

spring.security.oauth2.client.registration.github.clientId=5fefca2daccf85bede32
spring.security.oauth2.client.registration.github.clientSecret=01dde7a7239bd18bd8a83de67f99dde864fb6524``

配置redirect_uri

Spring Security OAuth2 内置了一个redirect_uri模板: {baseUrl}/login/oauth2/code/{registrationId} ,其中的 registrationId 是在从配置中提取出来的:

spring.security.oauth2.client.registration.[registrationId].clientId=xxxxx

如在上面的GitHub客户端的配置中,因为指定的 registrationId 是 github ,所以重定向uri地址就是:

{baseUrl}/login/oauth2/code/github

启动服务器

OAuth2客户端和重定向Uri配置好后,将服务器启动,然后打开浏览器进入: http://localhost:8080/ 。第一次打开因为没有认证会将浏览器重客向到GitHub的 授权Endpoint :

一文带你了解 OAuth2 协议与 Spring Security OAuth2 集成

常用授权服务器(CommonOAuth2Provider)

Spring Security OAuth2 内置了一些常用的授权服务器的配置,这些配置都在 CommonOAuth2Provider 中:

public enum CommonOAuth2Provider {

GOOGLE {

@Override
public 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 {

@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;
}
},

FACEBOOK {

@Override
public 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 {

@Override
public 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}";
}

CommonOAuth2Provider 中有四个授权服务器配置: OKTA 、 FACEBOOK 、 GITHUB 、 GOOGLE 。在OAuth2协议中的配置项 redirect_uri 、 Token Endpoint 、 授权 Endpoint 、 scope 都会在这里配置:

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;
}
}

重定向Uri拦截

脑瓜子有点蒙了,感觉自己就配置了 clientid 和 clientSecret 一个OAuth2客户端就完成了,其中的一些原由还没搞明白啊。。。,最好奇的是重定向Uri是怎么被处理的。

Spring Security OAuth2 是基于 Spring Security 的,之前看过 Spring Security 文章,知道它的处理原理是基于过滤器的,如果你不知道的话推荐看这篇文章: 《Spring Security 架构》 。在源码中找了一下,发现一个可疑的Security 过滤器:

  • OAuth2LoginAuthenticationFilter:处理OAuth2授权的过滤器

这个 Security 过滤器有个常量:

public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";

是一个匹配器,之前提到过 Spring Security OAuth2 中有一个默认的redirect_uri模板: {baseUrl}/{action}/oauth2/code/{registrationId} , /login/oauth2/code/* 正好能与redirect_uri模板匹配成功,所以 OAuth2LoginAuthenticationFilter 会在用户同意授权后执行,它的构造方法如下:

public OAuth2LoginAuthenticationFilter(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
this(clientRegistrationRepository, authorizedClientService, DEFAULT_FILTER_PROCESSES_URI);
}

OAuth2LoginAuthenticationFilter 主要将授权服务器返回的 code 拿出来,然后通过AuthenticationManager 来认证(获取AccessToken),下来是移除部分代码后的源代码:

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {

MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
//检查没code与state
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
//获取 OAuth2AuthorizationRequest
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());
}
//取出 ClientRegistration
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();

//认证、获取AccessToken
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);

...
return oauth2Authentication;
}

获取AccessToken

前面提到 OAuth2LoginAuthenticationFilter 是使用 AuthenticationManager 来进行OAuth2认证的,一般情况下在 Spring Security 中的 AuthenticationManager 都是使用的 ProviderManager 来进行认证的,所以对应在 Spring Security OAuth2 中有一个 OAuth2LoginAuthenticationProvider 用于获取AccessToken:

public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient;
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> userService;
private GrantedAuthoritiesMapper authoritiesMapper = (authorities -> authorities);

....

@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());

//访问GitHub TokenEndpoint获取Token
accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
new OAuth2AuthorizationCodeGrantRequest(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange()));

} catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
...
return authenticationResult;
}



@Override
public boolean supports(Class<?> authentication) {
return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication);
}
}

参考资料

  • OAuth 2 Developers Guide
  • draft-ietf-oauth-v2