™技术博客

框架 | 4.平台认证与授权系统设计

2021年8月11日

1.简述


权限系统的设计一般分为:权限设计=功能权限和数据权限

一般数据权限,是根据业务场景做特殊的设计,且必须在项目期就做好规划,不像功能权限那样可以后期完成。

2.功能权限


按照来源分为:外部请求内部请求; 其中外部请求分为 登录请求非登录请求
按照目标资源分为:无注解、@Inner注解(仅内部请求)、@PreAuthorize注解(带权限控制)

来源于资源关系

对于请求:主要是外部和内部的区别;外部请求必须经过网关,网关对于请求的处理主要由登录与非登录的区别。

  • 无注解:一般用于对外公开资源,如商品浏览,官网等互联网接口;对应的服务需要添加白名单设置:security.oauth2.client.ignore-urls后接口才可以访问(或不引入aiops-common-security 或 采用@Inner(false)注解)
  • @Inner注解:一般用于被内部应用请求的接口,如日志、定时任务、文件存储等支持型服务,被注解后该请求将无法被外部请求访问到(需要网关提供保护)
  • @PreAuthorize注解:用于外部请求非登录请求,该类请求须带token,因此登录后对用户访问资源的权限控制,微服务依赖aiops-common-security以后就有认证(spring security oauth2)控制了。认证控制负责的是对token的鉴定,而对接口本身是否有权限访问是由用户权限系统所控制,该注解就是在token鉴定成功以后,用户权限系统再基于token内容进行的权限控制。

3.与网关相关的权限功能设计


网关服务是所有服务的入口,目前在系统架构中主要由以下特殊作用的过滤器filter,对权限系统的工作起到了一定的作用

过滤器 作用
HttpBasicGatewayFilter 自定义basic认证,针对特殊场景使用
AiopsRequestGlobalFilter 清洗请求头中from 参数,用于防止外部模拟内部请求
PasswordDecoderFilter 对登录请求的密码参数进行解密处理
PreviewGatewayFilter 提供测试环境的支持
ValidateCodeGatewayFilter 对登录请求进行验证码检验

3.1.AiopsRequestGlobalFilter分析

内部服务请求通常不需要再通过auth服务进行一次鉴权,如A请求B时,如果B需要对A请求鉴权的话,A就需要拿到token,且B接送token后还需要请求auth服务鉴定token有效性,如果B在处理过程中还需要请求C,则C同样需要如此过程,不但复杂且给auth服务增添不少压力,一般的做法是网关请求A时,A进行一次鉴权,A到B,B到C的内部请求过程不需要再鉴权

为了实现B接口不鉴权,一般会将B所在服务中配置security.oauth2.client.ignore-urls,接口地址将不会鉴权

但单纯添加白名单是不行的,因为网关外部请求就可以直接获取到该接口资源

为了实现接口内部请求允许请求,外部请求不允许请求的目的,引入了注解@Inner

该注解的接口请被切面AiopsSecurityInnerAspect控制,控制逻辑很简单,只有请求头部带”from”标志时才允许访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SneakyThrows
@Around("@within(inner) || @annotation(inner)")
public Object around(ProceedingJoinPoint point, Inner inner) {
// 先判断 inner 是否为空, 为空则获取类上注解
if (inner == null) {
Class<?> aClass = point.getTarget().getClass();
inner = AnnotationUtils.findAnnotation(aClass, Inner.class);
}

String header = request.getHeader(SecurityConstants.FROM);
if (inner.value() && !StrUtil.equals(SecurityConstants.FROM_IN, header)) {
log.warn("访问接口 {} 没有权限", inner.value());
throw new AccessDeniedException(AiopsSecurityMessageSourceUtil.getAccessor().getMessage(
"AbstractAccessDecisionManager.accessDenied", new Object[] { inner.value() }, "access denied"));
}
return point.proceed();
}

由此一来,内部请求时就需要添加SecurityConstants.FROM_IN参数,保证不会被AiopsSecurityInnerAspect切面所拒绝

比如下面这段用户授权(Auth)的代码,请求用户中心 时带上了此参数来获取用户信息:

1
2
3
4
5
6
7
8
9
10
11
public UserDetails loadUserByUsername(String username) {
Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS);
if (cache != null && cache.get(username) != null) {
return cache.get(username, AiopsUser.class);
}

Resp<UserInfo> result = remoteUserService.info(username, SecurityConstants.FROM_IN);
UserDetails userDetails = getUserDetails(result);
cache.put(username, userDetails);
return userDetails;
}

而用户信息接口上对应加入了@Inner注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 获取指定用户全部信息
* @return 用户信息
*/
@Inner
@GetMapping("/info/{username}")
public Resp info(@PathVariable String username) {
SysUser user = userService.getOne(Wrappers.<SysUser>query().lambda().eq(SysUser::getUsername, username));
if (user == null) {
return Resp.failed(null, String.format("用户信息为空 %s", username));
}
return Resp.ok(userService.findUserInfo(user));
}

但外部请求可以通过网关访问白名单接口,同样也可以模拟头部带“from”的内部请求

因此AiopsRequestGlobalFilter的作用就是防止外部模拟头部带“from”的请求来访问内部资源,从源码中可以看到将请求头部对“from”统一进行了去除:

1
2
ServerHttpRequest request = exchange.getRequest().mutate()
.headers(httpHeaders -> httpHeaders.remove(SecurityConstants.FROM)).build();

3.2.PasswordDecoderFilter分析

考虑到登录请求密码参数在传输过程中的安全性,前端对密码文本进行了加密处理:

PasswordDecoderFilter用于对登录密码中的密码参数进行解密处理:

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
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// 1. 不是登录请求,直接向下执行
if (!StrUtil.containsAnyIgnoreCase(request.getURI().getPath(), SecurityConstants.OAUTH_TOKEN_URL)) {
return chain.filter(exchange);
}

// 2. 刷新token类型,直接向下执行
String grantType = request.getQueryParams().getFirst("grant_type");
if (StrUtil.equals(SecurityConstants.REFRESH_TOKEN, grantType)) {
return chain.filter(exchange);
}

// 3. 判断客户端是否需要解密,明文传输直接向下执行
if (!isEncClient(request)) {
return chain.filter(exchange);
}

// 4. 前端加密密文解密逻辑
Class inClass = String.class;
Class outClass = String.class;
ServerRequest serverRequest = ServerRequest.create(exchange, messageReaders);

// 解密生成新的报文
Mono<?> modifiedBody = serverRequest.bodyToMono(inClass).flatMap(decryptAES());

BodyInserter bodyInserter = BodyInserters.fromPublisher(modifiedBody, outClass);
HttpHeaders headers = new HttpHeaders();
headers.putAll(exchange.getRequest().getHeaders());
headers.remove(HttpHeaders.CONTENT_LENGTH);

headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);
return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> {
ServerHttpRequest decorator = decorate(exchange, headers, outputMessage);
return chain.filter(exchange.mutate().request(decorator).build());
}));
};
}

3.3.ValidateCodeGatewayFilter分析

网关提供了验证码的实现,在RouterFunctionConfiguration中对/code接口提供了imageCodeHandler对象,用于生成验证码:

1
2
3
4
5
6
7
8
9
10
public RouterFunction routerFunction() {
return RouterFunctions
.route(RequestPredicates.path("/code").and(RequestPredicates.accept(MediaType.TEXT_PLAIN)),
imageCodeCreateHandler)
.andRoute(RequestPredicates.GET("/imagecode")
.and(RequestPredicates.accept(MediaType.TEXT_PLAIN)), imageCodeHandler)
.andRoute(RequestPredicates.POST("/code/check").and(RequestPredicates.accept(MediaType.ALL)),
imageCodeCheckHandler);

}

ValidateCodeGatewayFilter的作用是在登录请求中获取用户输入的验证证参数,验证用户输入是否正确

4.与外部请求相关的权限功能设计


4.1.服务层面的外部请求权限控制

对于外部请求内部资源,除非是不需要权限控制的资源接口,否则我们开发的新微服务模块都应该依赖平台的aiops-common-security组件:

该组件结合aiops-usercenter-api与spring security oauth2框架进行了封装,从而实现系统用户与权限的通关:

引入aiops-common-security组件以后,nacos配置中心需要配置client-id、client-secret、scope:

1
2
3
4
5
6
security:
oauth2:
client:
client-id: ENC(gPFcUOmJm8WqM3k3eSqS0Q==)
client-secret: ENC(gPFcUOmJm8WqM3k3eSqS0Q==)
scope: server

我们所写的每个微服务都是一个client,对应在后台“终端管理(sys_oauth_client_details)”中进行设置:

因此每个微服务在引入aiops-common-security依赖以后,处理外部、非登录请求时,除非请求地址已加入白名单,否则都需要在Auth中认证请求访问者的身份:

以访问Service服务请求为例,过程如下:

  1. 客户端通过带token字符串的请求通过网关(Gateway)访问后端API
  2. 网关将请求路由到具体对应业务服务(Service)
  3. 业务服务(Service)首先会请求认证服务(Auth)来验证token
  4. token验证成功后请求进入具体接口请求逻辑中

我们系统中有不同的服务会拿token去访问Auth服务进行认证,来判断请求是否合法:

而判断请求是否合法(即/oauth/check_token)的过程中,不同服务中配置的不同client_id与client_secret,就起到了目标应用认证用户请求时本身目标应用认证的作用,这是因为Auth服务是OAuth2协议的实现,OAuth2协议把所有对自身的请求做为不同的client来源来对待,可以在sys_oauth_client_details表中看到client分布情况:

如此一来,不同目标应用对应与Auth中client的关系如下(注:以上只列出部分应用):

值得一提的是,前端登录时的认证请求通过网关直接访问Auth服务也是属于一种client来源(client_id : aiops)

4.2.功能层面的外部请求权限控制

外部请求通过了服务层面的权限控制以后,还有更细化的功能(接口)层面的权限控制

可设置**用户->角色->菜单(权限)**关系

在“用户管理”功能中,可对用户“编辑”操作,进行角色设定:

在”角色管理”功能中,可对角色“+权限”操作,进行权限设置:

每个权限菜单(sys_menu)对应有一个“permission”字段,用于功能层面的权限控制

因为Spring Security Oauth2是基于Spring Security的,因此自然采用了Spring Security中的@PreAuthorize注解完成对接口访问权限的控制

@PreAuthorize通过指定PermissionService类的hasPermission()方法进行具体访问控制:

1
2
3
4
5
6
7
8
9
10
11
12
public boolean hasPermission(String... permissions) {
if (ArrayUtil.isEmpty(permissions)) {
return false;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
return authorities.stream().map(GrantedAuthority::getAuthority).filter(StringUtils::hasText)
.anyMatch(x -> PatternMatchUtils.simpleMatch(permissions, x));
}

因此在以上基础之上,只需接口方法添加@PreAuthorize注解,即可实现功能层面的权限控制,如:

1
2
3
4
5
6
7
8
9
@SysLog("删除用户信息")
@DeleteMapping("/{id}")
@PreAuthorize("@pms.hasPermission('sys_user_del')")
@ApiOperation(value = "删除用户", notes = "根据ID删除用户")
@ApiImplicitParam(name = "id", value = "用户ID", required = true, dataType = "int", paramType = "path")
public Resp userDel(@PathVariable Integer id) {
SysUser sysUser = userService.getById(id);
return Resp.ok(userService.deleteUserById(sysUser));
}

hasPermission()方法在第5行从SecurityContextHolder.getContext().getAuthentication()中取得了用户信息,该信息是由OAuth2AuthenticationProcessingFilter过滤器放入其中的,追溯操作权限获取过程如下:

OAuth2AuthenticationProcessingFilter过滤器就是实现服务层面鉴权时的主要逻辑:

通过doFilter()方法对请求过滤处理,处理逻辑会访问OAuth2AuthenticationManager.authenticate()方法,authenticate()方法实际是访问RemoteTokenServices的loadAuthentication()方法,RemoteTokenServices是ResourceServerTokenServices接口的远程访问方式实现,实际请求到了Auth服务的/oauth/check_token接口,该接口专用于对token验证的支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException {

MultiValueMap<String, String> formData = new LinkedMultiValueMap<String, String>();
formData.add(tokenName, accessToken);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", getAuthorizationHeader(clientId, clientSecret));
Map<String, Object> map = postForMap(checkTokenEndpointUrl, formData, headers);

if (map.containsKey("error")) {
if (logger.isDebugEnabled()) {
logger.debug("check_token returned error: " + map.get("error"));
}
throw new InvalidTokenException(accessToken);
}

// gh-838
if (map.containsKey("active") && !"true".equals(String.valueOf(map.get("active")))) {
logger.debug("check_token returned active attribute: " + map.get("active"));
throw new InvalidTokenException(accessToken);
}

return tokenConverter.extractAuthentication(map);
}

/oauth/check_token 接口的checkToken()方法实现中:

  1. ResourceServerTokenServices接口采用DefaultTokenServices类实现,该类中包含TokenStore接口对象,该对象使用RedisTokenStore实现
  2. 通过跟踪发得在验证token后,会从redis中拿出authentication相关的信息,其中就附带了authorities信息,该信息是用户token对应的接口访问控制权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RequestMapping(value = "/oauth/check_token")
@ResponseBody
public Map<String, ?> checkToken(@RequestParam("token") String value) {

OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value);
if (token == null) {
throw new InvalidTokenException("Token was not recognised");
}

if (token.isExpired()) {
throw new InvalidTokenException("Token has expired");
}

OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue());

return accessTokenConverter.convertAccessToken(token, authentication);
}

由此可见,服务层面的权限鉴定操作(/oauth/check_token)完成后,从用户会话的上下文中便可以取得功能(接口)层面的权限信息
(SecurityContextHolder.getContext().getAuthentication()),即功能层面的权限控制是基于服务层面权限控制之上的,其条件为:用户已登录、请求带token并验证通过、用户角色权限已添加

4.3.与内部请求相关的权限功能设计


假如我们当前开发的业务主体为某个微服务模块,那么我们编写的接口服务将会接受到两类请求:

  1. 从外部经过网关路由而来的外部请求
  2. 从内部其它服务通过RestTemplate、Netty等方式而来的内部请求

其中外部请求是最常规的权限控制,以上已经进行说明

而内部请求,结合网关对此做专门的设计,其中网关设计部分主要是AiopsRequestGlobalFilter,已经进行说明,除此之外就是@Inner注解与AiopsSecurityInnerAspect切面,下面进行说明:

@Inner注解定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Inner {

/**
* 是否AOP统一处理
* @return false, true
*/
boolean value() default true;

/**
* 需要特殊判空的字段(预留)
* @return {}
*/
String[] field() default {};

}

对于只允许内部系统访问的接口,应添加@Inner注解:

1
2
3
4
5
6
7
8
9
10
/**
* 令牌管理调用
* @param token token
* @return
*/
@Inner
@DeleteMapping("/{token}")
public Resp<Boolean> delToken(@PathVariable("token") String token) {
return dealService.removeToken(token);
}

此时如果我们通过外部调用此接口,将会被拒绝:

AiopsSecurityInnerAspect负责对切面处理外部访问带有@Inner注解的接口时,做权限拒绝处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SneakyThrows
@Around("@within(inner) || @annotation(inner)")
public Object around(ProceedingJoinPoint point, Inner inner) {
// 先判断 inner 是否为空, 为空则获取类上注解
if (inner == null) {
Class<?> aClass = point.getTarget().getClass();
inner = AnnotationUtils.findAnnotation(aClass, Inner.class);
}

String header = request.getHeader(SecurityConstants.FROM);
if (inner.value() && !StrUtil.equals(SecurityConstants.FROM_IN, header)) {
log.warn("访问接口 {} 没有权限", inner.value());
throw new AccessDeniedException(AiopsSecurityMessageSourceUtil.getAccessor().getMessage(
"AbstractAccessDecisionManager.accessDenied", new Object[] { inner.value() }, "access denied"));
}
return point.proceed();
}

利用@Inner注解添加接口权限白名单(ignore urls)

@Inner注解除了可以用于防止外部请求访问,还可以为接口起到添加白名单的作用,只需在注解中加入false参数:@Inner(false)

该参数默认为true时,做为内部调用接口,反之为false时,做可为外部调用无须鉴权的接口(对外公开资源,如商品浏览、官网等互联网接口)

5.登录认证功能设计


以上都是授权以后的权限控制逻辑,Spring Security提出了两个概念:认证与授权,其中授权可以理解为认证成功以后为client颁发证明(token)以及鉴定证明,而下面介绍的认证就是client为了获取证明(token)向Auth服务请求认证的过程:

5.1.认证过程场景

网关(Gateway)并没有对认证与授权过程做太多业务处理,只是简单的将登录请求进行了特殊的对待,配合内部请求权限去除SecurityConstants.FROM参数,其它请求处理都是一视同仁

Auth服务在基于Spring Security OAuth2基础上对/oauth/authorize做了处理,大部分情况下只需要提供配置及一部分简单实现就能实现授权与认证,Spring Security OAuth2的使用基本上是按官方标准方式来实现的,这里就不再赘述了,有兴趣的可自行研究

6.总结


总结一下平台中的权限体系,按应用功能分为有以下三部分:

  1. Spring Security:认证与授权
  2. Spring Security OAuth2:基于Spring Security之上实现OAuth2协议
  3. aiops-common-security:基于Spring Security OAuth2之上,封装成平台专用安全组件,并提供@Inner @PreAuthorizet等更细精的权限控制

或者按鉴权的不同分为三类:

  1. 其它应用请求Auth服务功能时,Auth服务要求的应用提供的client级别鉴权(对应后台“终端管理”中添加)
  2. 应用从Auth认证成功拿到授权以后,再来请求后台服务接口时的会话级鉴权(token),/oauth/check_token接口
  3. 应用顺利通过上一步会话级鉴权以后,进入应用级鉴权(对应后台“用户、角色、权限”的配置、代码中@PreAuthorize、@Inner的编写)

扫描二维码,分享此文章