微服务开发中,使用AOP和自定义注解实现对权限的校验

一、背景

微服务开发中,暴露在外网的接口,为了访问的安全,都是需要在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, "用户没有此操作的权限");
}

可以说, 它的实现和角色的校验如出一辙,不同的是,往往权限项会更细致,也就是比角色的记录数更多罢了。

如果你采用的是权限项的校验,而非角色,那么请减少每次的查库操作,可以对缓存做一个恰当有效期。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值