一、背景
微服务开发中,暴露在外网的接口,为了访问的安全,都是需要在http请求中传入登录时颁发的token。这时候,我们需要有专门用来做校验token并解析用户信息的服务。如下图所示,http请求先经过api网关,网关会去调用认证服务进行token解析(因为token是认证服务所颁发),反解析出token中包含的用户信息,最后经过http header透传给业务服务(供业务服务直接使用)。
本文主要是描述业务服务中,如何对api网关透传过来的报文进行权限的校验。
这里重申一下,建议每个服务自己去实现权限的校验。虽然工作量有的时候会重复,但是适用于中小公司没有统一权限管理的实际情况。
本文会涉及到的几个知识点:
- AOP切面编程
- 自定义注解
二、自定义注解
- 权限开关
- 用户ID,需读取注解所在方法的入参值
- 角色列表,限定方法访问所需的角色列表,这里默认是教师-teacher,就是说登录用户的角色必须含有教师角色。
import java.lang.annotation.*;
/**
* 权限限制.
*
* @author xxx
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PermissionLimit {
/**
* 权限校验(默认true)
*/
boolean limit() default true;
/**
* 入参-用户ID
*
* @return
*/
String userId();
/**
* 角色列表(默认teacher-教师)
*
* @return
*/
String[] roles() default {Constants.RoleType.TEACHER};
}
允许访问的角色列表,这里使用数组的方式, 因为一个用户可能有多个角色,而一个方法也可能被多个角色所允许访问。
本系统为了简单讲解,角色只有以下2个:
public static class RoleType {
/**
* 学生
*/
public static final String STUDENT = "student";
/**
* 老师
*/
public static final String TEACHER = "teacher";
}
三、EL表达式
使用@Aspect对自定义注解PermissionLimit进行拦截,读取注解中的userId,和透传参数进行对比。
要读取注解中的userId,就需要支持el表达式,可能有下面两种情况:
- 对象.属性
@PostMapping("/order/copy")
@PermissionLimit(userId = "#request.userId")
public ResponseEntity<?> copy(@Validated @RequestBody OrderCopyRequest request) {
}
- 变量
@PostMapping("/order/create")
@PermissionLimit(userId = "#userId")
public ResponseEntity<?> create(@RequestParam Long userId) {
}
Java中有对el表达式支持解析:
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
private final ExpressionParser expressionParser = new SpelExpressionParser();
private LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
// elExpression 即#request.userId 或者 #userId
// method 注解所在的方法
// args 方法的参数值
private Object evaluateExpression(String elExpression, Method method, Object[] args) {
Expression expression = expressionParser.parseExpression(elExpression);
EvaluationContext context = this.bindParam(method, args);
return expression.getValue(context);
}
private EvaluationContext bindParam(Method method, Object[] args) {
// 获取方法的参数名
String[] params = discoverer.getParameterNames(method);
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < params.length; i++) {
// 把方法的参数值赋给EvaluationContext
context.setVariable(params[i], args[i]);
}
return context;
}
四、HttpServletRequest
自定义注解只能修饰controller层的方法,它需要读取http header的透传字段。
所以,前提是获得HttpServletRequest对象,具体语句见下:
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
private HttpServletRequest getRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
return null;
}
接下来,读取http header中的透传字段userId,实现语句如下:
HttpServletRequest request = this.getRequest();
if (null != request) {
//2.当前登录用户的userId
final String authUserId = request.getHeader(JwtAuthHeaders.AUTH_USER_ID);
}
五、AOP切面
- PermissionLimit是我们的自定义注解
@Component
@Aspect
public class PermissionAspect {
@Autowired
private CommonConfig commonConfig;
@Pointcut("@annotation(permissionLimit)")
public void pointcut(PermissionLimit permissionLimit) {
}
@Around("pointcut(permissionLimit)")
public Object around(ProceedingJoinPoint joinPoint, PermissionLimit permissionLimit) throws Throwable {
// 1.开关是否开启(全局开关和注解的开关)
if (!commonConfig.getEnabledPermission() || !permissionLimit.limit()) {
return joinPoint.proceed();
}
Method method = this.getMethod(joinPoint);
Object[] args = joinPoint.getArgs();
HttpServletRequest request = this.getRequest();
if (null != request) {
//2.从token中解析出当前登录用户的userId
final String authUserIdStr = request.getHeader(JwtAuthHeaders.AUTH_USER_ID);
Precondition.isTrue(StrUtil.isNotBlank(authUserIdStr), "用户未登录");
//3.是否一致
String userId = this.evaluateExpression(permissionLimit.userId(), method, args).toString();
Precondition.isTrue(authUserIdStr.equals(userId), "用户不一致");
//4.角色校验
final String userRoles = request.getHeader(JwtAuthHeaders.AUTH_USER_ROLE);
Precondition.isTrue(StrUtil.isNotBlank(userRoles), "未获取到登录用户的角色");
String[] authorityRoleArray = permissionLimit.roles();
Set<String> authorityRoleSet = Arrays.stream(authorityRoleArray).collect(Collectors.toSet());
if (!CollectionUtils.isEmpty(authorityRoleSet)) {
boolean hasAuthority = false;
String[] userRoleArray = userRoles.split(",");
for (String role : userRoleArray) {
// 用户的任意一个角色被包含在里面,则说明拥有此方法的权限
hasAuthority = authorityRoleSet.contains(role);
if (hasAuthority) {
break;
}
}
Precondition.isTrue(hasAuthority, "用户没有此操作的权限");
}
}
return joinPoint.proceed();
}
}
六、总结
本文总结下整个的权限校验流程:
- 全局开关, 是针对整个项目而言,在不同的环境下,开或关,方便调试。(如果是本地就需要关闭,而生产环境才打开。)
- 方法开关,多少有点鸡肋了,好在它有默认值,不会增加你使用的复杂度。
权限项的校验
本文实现了角色的校验,如果要细到权限项的话,需要查询业务服务中用户配置的权限项列表。
下面仅给出其伪代码实现,以供参考。
// 避免每次都查库,可以适当缓存一定时间
String[] authorityArray = permissionLimit.authority();
Set<String> authoritySet = Arrays.stream(authorityArray).collect(Collectors.toSet());
if (!CollectionUtils.isEmpty(authorityRoleSet)) {
boolean hasAuthority = false;
List<String> authorities = userService.getUser(userId);
for (String authority : authorities) {
// 用户的任意一个权限项被包含在里面,则说明拥有此方法的权限
hasAuthority = authoritySet.contains(authority);
if (hasAuthority) {
break;
}
}
Precondition.isTrue(hasAuthority, "用户没有此操作的权限");
}
可以说, 它的实现和角色的校验如出一辙,不同的是,往往权限项会更细致,也就是比角色的记录数更多罢了。
如果你采用的是权限项的校验,而非角色,那么请减少每次的查库操作,可以对缓存做一个恰当有效期。