微服务认证方案设计之spring-security-oauth(二)

上一章节中,我们介绍了大体的概念。这一章节将主要讲解oauth2.0 + jwt + spring security的开发知识

整体技术框架

  • 认证中心(授权服务器): spring security oauth 2.1.2.RELEASE
  • 鉴权框架: spring security 5.3.8.RELEASE
  • 认证协议: oauth2.0
  • 无状态令牌: jwt

认证中心框架选择

方案一: 升级现有的cas4.3至cas6.x版本:

总体上来说,6.x版本cas从功能上来说是满足我们的要求的,但是cas6.x有以下不足,让我最终放弃了使用它:

  • 采用了gradle作为包管理器,而项目组现在只用maven,
  • 6.x版本的cas将包拆分的过于细了,导致阅读与寻找源码并不方便,
  • cas6.x官方并不推荐也不支持你去修改它的登录流程,自定义流程太麻烦了

综上,cas6.x适合那些登录流程简单较为固定,需要开箱即用的项目,不适合那些需要自定义修改很多内容的项目。对于企业应用来说,它推荐的使用方式是war包overlay部署方式,里面强塞了太多不需要的东西,太沉重了

还有一点,6.x版本的cas中文文档与博客极少,遇到问题只能看源码,比较痛苦。

方案二: 采用spring-security-oauth

虽然spring-security-oauth已经进入了维护阶段,后续官方将废弃该项目,现在spring官方已经将spring-security-oauth中的大部分功能(除了认证服务器)合并入了spring-security中统一管理,后续官方将为认证服务器单独推出一个全新的项目: spring-authorization-server。它目前还处于实验阶段,所以不敢采用。

而spring-security-oauth作为认证中心,虽然已经进入了停止更新状态,但官方依然会维护一段时间。并且作为一个很成熟的spring框架,它预留了很多的方法来供我们自定义,修改起来非常方便。并且设计上也很简单清爽,便于阅读,中文文档和博客也是很多的,所以我采用了这个框架作为我们的认证中心。

鉴权框架选择

shiro 和 spring-security在功能上都是很好的框架,但Shiro 最大的问题在于和 Spring 家族的产品进行整合的时候非常不便,对比之下,虽然spring-security相对Shiro会复杂很多,但基于 Spring Boot/Spring Cloud 的微服务项目基本上是原生支持spring-security的,对接起来非常方便。因此选择spring-security。

认证协议与令牌方案

oauth2.0+jwt, oauth2.0只是一种认证协议,具体的令牌方案目前最流行的就是自解析的jwt,很适合微服务方案。

问题: 假如微服务框架使用非自解析的令牌,相对于jwt有什么优势又有什么劣势呢?


==优势: 安全性更高,对令牌的控制更方便 劣势: 后端服务器压力大,一次认证流程中会有更多的http请求==

具体技术讲解

oauth2.0与jwt非常简单,这里就不仔细讲了。主要说一下认证中心与鉴权框架。

demo演示(几种模式及其参数解释)

这里推荐一个个人认为很好的demo, 地址如下

这个项目拉下来直接就可以跑,不需要配置任何数据库,按照readme中的提示一步步跑起来,然后主要要体会一下它的几种模式的调用和弄清楚它各个模式里面的参数都代表什么意思,代码原理倒不怎么用看

  • 密码模式

密码模式请求的是 /oauth/token 这个接口,一共有五个参数:

参数名 位置 作用 说明
username Authorization中Type为Basic Auth的username选项 对应于oauth服务端中注册好的clientId 无论是哪个客户端接入都必须提前注册clientId和clientSecret
password Authorization中Type为Basic Auth的password选项 对应于oauth服务端中注册好的clientSecret 同上
username Body中 用户名
password Body中 密码
grant_type Body中 授权类型 这个参数决定你调用的是哪种模式,密码模式为password
  • 授权码模式

第一步,浏览器访问这个地址 http://localhost:8001/oauth/authorize?response_type=code&scope=sever&client_id=yaohw&redirect_uri=http://www.baidu.com&state=0583 然后会重定向到登录页

参数名 解释
response_type 授权码模式固定为code
scope 请求的token的作用范围
client_id 在oauth2认证中心注册的客户端Id
redirect_uri 登录成功后的重定向地址,该地址必须与在oauth2认证中心注册的重定向地址完全一致
state 自定义参数,忽略

第二步,在页面输入用户名和密码,就会带着授权码?code=xxx跳转到上面哪个地址,将授权码拷贝下来

第三步,post访问http://localhost:8001/oauth/token,参数如下

访问/oauth/token时候,请求头Authorization中都必须配置Type为Basic Auth,然后将clientId和clientSecret设入,后面就不讲解这个了

参数名 解释
grant_type 这里对应获取token的模式,授权码模式必须为authorization_code
code 上一步获取的授权码
redirect_uri 登录成功后的重定向地址,该地址必须与在oauth2认证中心注册的重定向地址完全一致

下面三种模式就不多说了,你们可以仔细观察下调用的几个接口的区别,可以发现以下几点区别

只要是前端页面跳转,都是调用http://localhost:8001/oauth/authorize?response_type=这个地址,并且是通过response_type这个参数来区分模式

而如果是后端接口调用,则是通过http://localhost:8001/oauth/token这个接口来调用,并且通过grant_type这个字段来区分模式,并且一定带有clientId和clientSecret

  • 自定义手机验证码模式(这个可以忽略,需要搞redis,是作者自定义的,不嫌麻烦也可以试下)
  • 简化模式
  • 刷token模式

从接口开始走一遍各个模式的流程

因为上面我们发现oauth2.0的四种模式,其实就对应着两个地址/token和/authorize.因此接下来,我们就过一下着两个url对应的入口TokenEndpoint和AuthorizationEndpoint

AuthorizationEndpoint

对应认证码模式和纯前端的简化模式,具体实现类为org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint

总体入口在122行的authorize方法这里,下面我会把没用的代码都删掉,通过注释带大家看一遍这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@RequestMapping(value = "/oauth/authorize")
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
SessionStatus sessionStatus, Principal principal) {

//通过工厂类将参数包裹为authorizationRequest
AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);

//校验该方法中的response_type参数
Set<String> responseTypes = authorizationRequest.getResponseTypes();
if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
throw new UnsupportedResponseTypeException("Unsupported response types: " + responseTypes);
}

//校验是否提供clientId
if (authorizationRequest.getClientId() == null) {
throw new InvalidClientException("A client id must be provided");
}

try {

/**
这里非常重要!!!假如这里你没有登录,这里会直接抛出AuthenticationException,
而这个异常会被过滤器链filterChain上的spring security 专用的异常处理过滤器捕捉,
对应的是org.springframework.security.web.access.ExceptionTranslationFilter的105行
的handleSpringSecurityException这个方法,捕捉后根据不同的实现类
spring会执行不同的操作,对应于认证码模式,它就会重定向走

因此假如你的项目有全局异常处理器,这里一定要记得不要去捕捉所有异常,如果
把spring security的异常捕捉了,就会产生意料外的缺陷
**/
if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
throw new InsufficientAuthenticationException(
"User must be authenticated with Spring Security before authorization can be completed.");
}

//这里通过用户传来的clientId去后端查这个客户端的信息,至于具体是去哪里查,
//依赖于用户具体的service实现方式是jdbc还是redis
ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());

//解析并判断redirect_uri中指定的参数是否与后端配置的一样
String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
if (!StringUtils.hasText(resolvedRedirect)) {
throw new RedirectMismatchException(
"A redirectUri must be either supplied or preconfigured in the ClientDetails");
}
authorizationRequest.setRedirectUri(resolvedRedirect);

//检查scope参数是否与后端配置的一样
oauth2RequestValidator.validateScope(authorizationRequest, client);

//这里是用来判断当你采用认证码模式时,跳转过去后,是否需要自动批准第三方客户端使用你的某些信息
authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest,
(Authentication) principal);
boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
authorizationRequest.setApproved(approved);

//假如你点了批准或者设置为默认批准
if (authorizationRequest.isApproved()) {
//如果你的response_type是token,则这里直接返回token给前端
if (responseTypes.contains("token")) {
return getImplicitGrantResponse(authorizationRequest);
}
//response_type为code,这里生成code后,return一个重定向的modelAndView完成跳转
if (responseTypes.contains("code")) {
return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
(Authentication) principal));
}
}

// Store authorizationRequest AND an immutable Map of authorizationRequest in session
// which will be used to validate against in approveOrDeny()
model.put(AUTHORIZATION_REQUEST_ATTR_NAME, authorizationRequest);
model.put(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME, unmodifiableMap(authorizationRequest));

return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);
}
catch (RuntimeException e) {
sessionStatus.setComplete();
throw e;
}

总结一下,这个方法对登录和未登录的访问会进行以下两种处理流程:

  • 假如你是未登录来访问这个接口,那么会抛出异常,被ExceptionTranslationFilter捕获,调用handleSpringSecurityException方法,进入sendStartAuthentication方法,假如你配置的是重定向地址,他这里应该就会找到这个LoginUrlAuthenticationEntryPoint,给个重定向到登录页,让你登录。
  • 当你登录server之后,再进这里就是已登录状态了,就直接过去了,然后给你生成code重定向走
TokenEndpoint

对应密码模式和纯后端的client_credentials模式以及拿着code请求token的认证码模式的第二步,具体实现类为org.springframework.security.oauth2.provider.endpoint.TokenEndpoint

总体入口在87行的postAccessToken方法这里,下面我会把没用的代码都删掉,通过注释带大家看一遍这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {

//在到达这个入口之前,UsernamePasswordAuthenticationFilter会
//把Authorization头中的clientId和clientSecret取出来,放到principal中。
//因此下面这段主要是对clientId和clientSecret进行校验
if (!(principal instanceof Authentication)) {
throw new InsufficientAuthenticationException(
"There is no client authentication. Try adding an appropriate authentication filter.");
}
String clientId = getClientId(principal);
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

if (clientId != null && !clientId.equals("")) {
// Only validate the client details if a client authenticated during this
// request.
if (!clientId.equals(tokenRequest.getClientId())) {
//如果不相等或者没找到,则直接抛出异常
throw new InvalidClientException("Given client ID does not match authenticated client");
}
}
//校验scope是否匹配
if (authenticatedClient != null) {
oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
}
//下面都是校验各种参数
if (!StringUtils.hasText(tokenRequest.getGrantType())) {
throw new InvalidRequestException("Missing grant type");
}
if (tokenRequest.getGrantType().equals("implicit")) {
throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
}

if (isAuthCodeRequest(parameters)) {
// The scope was requested or determined during the authorization step
if (!tokenRequest.getScope().isEmpty()) {
logger.debug("Clearing scope of incoming token request");
tokenRequest.setScope(Collections.<String> emptySet());
}
}

if (isRefreshTokenRequest(parameters)) {
// A refresh token has its own default scopes, so we should ignore any added by the factory here.
tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
}

//到这里就来到了整个token方法的核心,这一步会生成token,这里spring security设计的很好
//灵活性非常高,通过grantType为每个模式指定不同的TokenGranter,将生成token的操作
//委托给子类
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
if (token == null) {
throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
}

return getResponse(token);

}

总结一下,token模式,假如你未登录来访问这个接口,也会抛出异常。这个接口的登录是通过配置认证头来实现的,username配置clientId,password配置clientSecret.然后在UsernamePassword*Filter中捕获并注入principal。

这里如果登录无误,就会进入到最重要的方法里,132行的grant中,然后进行生成token操作

然后看一下重要的组件

上一章看完了,那么核心问题转化为了,spring security是怎么生成token的。而生成token的核心方法对应于org.springframework.security.oauth2.provider.TokenGranter这个接口的grant方法.接下来让我们看下这个方法,同时再去了解下spring security的核心组件吧。

TokenGranter

话不多说,直接看代码

首先看一下继承关系:
7
在上面的token模式中,grant方法默认会进入到AbstractTokenGranter的grant方法中,而假如我们需要自定义认证模式,一般做法也是去继承这个抽象的父类,比如MobileCodeTokenGranter

因此直接看下这个自定义的MobileCodeTokenGranter的代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 自定义grant_type模式-手机号短信验证模式
* @author: yaohw
* @create: 2019-09-29 18:29
**/
public class MobileCodeTokenGranter extends AbstractTokenGranter {

//根据你grant_type参数的不同,这里最后会进入到不同的tokenGranter
//因此如果需要自定义认证模式,一定要修改这个参数
private static final String GRANT_TYPE = "mobile";

private final AuthenticationManager authenticationManager;

public MobileCodeTokenGranter(AuthenticationManager authenticationManager,
AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
}

@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

//将参数组装为MobileCodeAuthenticationToken
Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters());
String mobile = parameters.get("mobile");
String code = parameters.get("code");

Authentication userAuth = new MobileCodeAuthenticationToken(mobile,code);
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
try {
//最后将这个MobileCodeAuthenticationToken交给authenticationManager来认证
userAuth = authenticationManager.authenticate(userAuth);
}
......
}
}

因此认证的核心方法最后交给了AuthenticationManager这个类,而这个类又是通过与其他的组件交互,最后完成认证的:

下面的内容建议直接看该博客:https://www.jianshu.com/p/7b87ec108405
8

简单的说就是,spring security通过不同的grantType,将颁发token的操作委托给了TokenGranter。

而TokenGranter通过将不同的登录参数组装为不同的token,然后将token委托给AuthenticationMnager来处理。

AuthenticationManager根据不同的token类型,通过调用AuthenticationProvider接口的support方法来决定,将这个token委托给哪个provider处理,最后的主要校验逻辑都是由provider来实现的。

spring security oauth 认证服务器

说到底oauth-server其实就是在spring-security的基础上增加了两个入口和一系列过滤器还有上面这些认证组件,两个入口和这些认证组件上面已经分析过了。剩下的核心其实还是spring-security,通过这一系列的过滤器,spring 完成了一系列鉴权与跳转操作,同时为我们预留了很多接口,方便我们自定义

spring security 本身并不复杂,它的核心原理可以用这张图和三局话概括:

  • 整个框架的核心是一个过滤器,这个过滤器名字叫springSecurityFilterChain类型是FilterChainProxy
  • 核心过滤器里面是过滤器链(列表),过滤器链的每个元素都是一组URL对应一组过滤器
  • WebSecurity用来创建FilterChainProxy过滤器,HttpSecurity用来创建过滤器链的每个元素。

9

如果想仔细了解,推荐这个博客,上面三句话也是来自这个博客的: https://blog.csdn.net/zimou5581/article/details/102457672

另外spring security提供了非常多的过滤器,如果想要了解的话,可以看这个博客:https://cloud.tencent.com/developer/article/1551517

内网的auth-server 和 client 的项目结构和改造思路,以及如何配置使用

参见内网文档 以及 代码注释