一、背景
微服务开发中,暴露在外网的接口,为了访问的安全,都是需要在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++) {// 把方法的参数值赋给EvaluationContextcontext.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.当前登录用户的userIdfinal String authUserId = request.getHeader(JwtAuthHeaders.AUTH_USER_ID);}
五、AOP切面
- PermissionLimit是我们的自定义注解
@Component
@Aspect
public class PermissionAspect {@Autowiredprivate 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中解析出当前登录用户的userIdfinal 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, "用户没有此操作的权限");
}
可以说, 它的实现和角色的校验如出一辙,不同的是,往往权限项会更细致,也就是比角色的记录数更多罢了。
如果你采用的是权限项的校验,而非角色,那么请减少每次的查库操作,可以对缓存做一个恰当有效期。