likes
comments
collection
share

用注解实现接口权限动态控制

作者站长头像
站长
· 阅读数 5

一、实现流程:

系统中存在多种用户角色,比如说超级管理员、代理商、门店、服务商、终端用户等角色,每种角色有不同的权限,可以在接口层面去控制,比如某些接口只允许服务商使用,某些接口只允许终端用户使用等等,如果将权限控制硬编码在接口实现部分,会导致代码植入性太强,扩展性和可阅读性差,如果我们在接口上添加一个注解,指定接口需要哪些角色的权限才能执行,这种方式则显得更优雅。那么该如何实现通过注解来控制接口的角色权限了?注解只是定义了接口允许哪些角色执行,必须要有对注解的解析和判断控制才能实现接口的权限控制,我的实现思路如下:

1、定义注解,通过一个注解属性指定需要的权限集合;

2、在Spring容器加载完所有的bean之后,遍历所有的controller,过滤出所有加了这个注解的方法,读出注解的属性并缓存到redis中(缓存时以接口URL路径为key,支持的角色集合为value);

3、在网关层校验时,根据接口请求头里面的userId在缓存查找其角色,根据接口请求URL路径在第二步缓存的数据里面查找接口允许的角色集合,判断这个角色集合是否包含userId的角色,如果包含则允许执行,否则抛出权限拒绝的异常。

二、定义注解:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RoleAuth {
    /**
     * 角色类型
     *
     * @return
     */
    String[] roleTypes();
}

三、加载权限角色并缓存:

权限角色加载的时机必须是bean容器加载完成之后,恰好SpringBoot提供了一个扩展接口CommandLineRunner,这个接口只有一个run方法,其调用时机就是在所有bean加载完成之后调用:

//SpringApplication.java

public ConfigurableApplicationContext run(String... args) {
	.............
	SpringApplicationRunListeners listeners = getRunListeners(args);
	listeners.starting();
	............
	context = createApplicationContext();		
	prepareContext(context, environment, listeners, applicationArguments, printedBanner);
	refreshContext(context);
	afterRefresh(context, applicationArguments);		
	listeners.started(context);
	//这里开始调用runner
	callRunners(context, applicationArguments);
	listeners.running(context);
	
	return context;
}

private void callRunners(ApplicationContext context, ApplicationArguments args) {
	List<Object> runners = new ArrayList<>();
	runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());
	runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());
	AnnotationAwareOrderComparator.sort(runners);
	for (Object runner : new LinkedHashSet<>(runners)) {
		//调用ApplicationRunner
		if (runner instanceof ApplicationRunner) {
			callRunner((ApplicationRunner) runner, args);
		}
		//调用CommandLineRunner
		if (runner instanceof CommandLineRunner) {
			callRunner((CommandLineRunner) runner, args);
		}
	}
}

private void callRunner(CommandLineRunner runner, ApplicationArguments args) {
	(runner).run(args.getSourceArgs());
}

那么我们可以编写一个scanner,其实现CommandLineRunner接口,在run方法中扫描所有的controller,识别方法上的RoleAuth注解,读取注解属性信息并缓存到redis:

public class RoleAuthScanner implements CommandLineRunner {
    private static final Logger logger = LoggerFactory.getLogger(RoleAuthScanner.class);

    @Autowired
    private WebApplicationContext appContext;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private DistributedLockService distributedLockService;

    @Value("${spring.application.name:default}")
    private String applicationName;

    private static final String AUTH_PATH_KEY = "role:auth:%s:%s";

    private static final String AUTH_PATH_ROOT_KEY = "role:auth:%s:*";

    /**
     * 获取redis的缓存key
     *
     * @param urlPath
     * @return
     */
    private String getKey(String urlPath) {
        return String.format(AUTH_PATH_KEY, applicationName, urlPath);
    }

    private String getRootKey() {
        return String.format(AUTH_PATH_ROOT_KEY, applicationName);
    }

    @Override
    public void run(String... args) throws Exception {
        DistributedLock lock = distributedLockService.lockAndHold("RoleAuthScanner",
                "all", 5, TimeUnit.MINUTES);
        try {
            if(lock.isLocked()) {
                this.scan();
            }
        } finally {
            distributedLockService.unlock(lock);
        }
    }

    private void scan() {
        try {
            logger.info("同步角色权限开始");

            //首先清除上次留存在redis中的缓存
            String rootKey = getRootKey();
            Set<String> subKeys = stringRedisTemplate.keys(rootKey);
            stringRedisTemplate.delete(subKeys);

            Map<String, HandlerMapping> allRequestMappings = BeanFactoryUtils.beansOfTypeIncludingAncestors(appContext,
                    HandlerMapping.class, true, false);
            if (allRequestMappings.isEmpty()) {
                return;
            }
            for (HandlerMapping handlerMapping : allRequestMappings.values()) {
                //只需要RequestMappingHandlerMapping中的URL映射
                if (handlerMapping instanceof RequestMappingHandlerMapping) {
                    RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) handlerMapping;
                    Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
                    for (Map.Entry<RequestMappingInfo, HandlerMethod> requestMappingInfoHandlerMethodEntry : handlerMethods.entrySet()) {
                        RequestMappingInfo requestMappingInfo = requestMappingInfoHandlerMethodEntry.getKey();
                        HandlerMethod mappingInfoValue = requestMappingInfoHandlerMethodEntry.getValue();
                        Set<String> authTypeSet = new HashSet<>();
                        Boolean hasAnnation = false;
                        RoleAuth classRoleAuth = mappingInfoValue.getMethod().getDeclaringClass().getAnnotation(RoleAuth.class);
                        if (classRoleAuth != null) {
                            hasAnnation = true;
                            Collections.addAll(authTypeSet, classRoleAuth.roleTypes());
                        }
                        RoleAuth methodRoleAuth = mappingInfoValue.getMethodAnnotation(RoleAuth.class);
                        if (methodRoleAuth != null) {
                            hasAnnation = true;

                            if (methodRoleAuth.roleTypes() != null && methodRoleAuth.roleTypes().length > 0) {
                                Collections.addAll(authTypeSet, methodRoleAuth.roleTypes());
                            }
                        }
                        if (!hasAnnation) {
                            continue;
                        }
                        if (CollectionUtils.isEmpty(authTypeSet)) {
                            //如果角色集合为空,则只有管理员角色能访问
                            authTypeSet.addAll(RoleTypeConst.managerRoles);
                        }
                        Set<String> requestMethods = getRequestMethods(mappingInfoValue);
                        if (CollectionUtils.isEmpty(requestMethods)) {
                            continue;
                        }
                        PatternsRequestCondition patternsCondition = requestMappingInfo.getPatternsCondition();
                        for (String requestUrl : patternsCondition.getPatterns()) {
                            requestUrl = requestUrl.replaceAll("\\[|\\]", "").replaceAll("([\\{])([\\w]*)([\\}])", "").replaceAll("/+", "/");
                            if (requestUrl.endsWith("/")) {
                                requestUrl = requestUrl.substring(0, requestUrl.lastIndexOf("/"));
                            }

                            for (String requestMethod : requestMethods) {
                                String path = requestMethod + ":" + requestUrl;
                                String key = getKey(path);
                                authTypeSet.forEach(authType -> stringRedisTemplate.opsForSet().add(key, authType));
                            }
                        }
                    }
                }
            }
            logger.info("同步角色权限完成");
        } catch (Exception e) {
            logger.error("同步角色权限失败", e);
        }
    }

    private Set<String> getRequestMethods(HandlerMethod method) {
        Set<String> set = new HashSet<>();
        RequestMapping requestMapping = method.getMethodAnnotation(RequestMapping.class);
        if (requestMapping == null) {
            return set;
        }
        RequestMethod[] requestMethods = requestMapping.method();
        if (requestMethods.length == 0) {
            set.add("GET");
            set.add("POST");
            set.add("PUT");
            set.add("PATCH");
            set.add("HEAD");
            set.add("DELETE");
            set.add("OPTIONS");
            set.add("TRACE");
        } else {
            for (RequestMethod requestMethod : requestMethods) {
                if (requestMethod.equals(RequestMethod.GET)) {
                    set.add("GET");
                } else if (requestMethod.equals(RequestMethod.POST)) {
                    set.add("POST");
                } else if (requestMethod.equals(RequestMethod.PUT)) {
                    set.add("PUT");
                } else if (requestMethod.equals(RequestMethod.DELETE)) {
                    set.add("DELETE");
                } else if (requestMethod.equals(RequestMethod.PATCH)) {
                    set.add("PATCH");
                } else if (requestMethod.equals(RequestMethod.TRACE)) {
                    set.add("TRACE");
                } else if (requestMethod.equals(RequestMethod.OPTIONS)) {
                    set.add("OPTIONS");
                }
            }
        }
        return set;
    }
}

四、权限角色校验:

权限角色校验一般放在网关统一做校验,在网关上校验的代码如下:

private String checkPrmission(ServerWebExchange exchange) {
    HttpHeaders headers = exchange.getRequest().getHeaders();
    String userId = headers.getFirst(Constant.USER_ID);    
   
    //读取调用接口的用户所属的角色code
    String roleCode = getCurUserRoleCode(userId);
	
	//读取接口支持的角色集合
	Set<String> requestRoleSet = getUrlRoleRequestSet(exchange);
	//如果接口支持的角色集合不支持调用用户的角色,则返回权限拒绝错位
	if (!requestRoleSet.contains(roleCode)) {
        throw new PrmissionException("权限拒绝");
    }	
}

//读取userId所属的角色code
private String getCurUserRoleCode(String userId) {
    String key = String.format(Constant.TOKEN_KEY, userId);
    if (!stringRedisTemplate.hasKey(key)) {
        return null;
    }
    String valueStr = stringRedisTemplate.opsForValue().get(key);
    if (StringUtils.isEmpty(valueStr)) {
        return null;
    }
    JSONObject valueObj = JSON.parseObject(valueStr);
    return valueObj.getString("roleCode");
}

//读取接口支持的角色集合
private Set<String> getUrlRoleRequestSet(ServerWebExchange exchange) {
    String urlPath = exchange.getRequest().getPath().value();
    if (StringUtils.hasText(urlPath) && urlPath.startsWith("/")) {
        urlPath = urlPath.substring(1);
    }    
    //读取methodName
    String requestMethod = exchange.getRequest().getMethodValue();
    //读取该url地址要求的Role集合
    String path = requestMethod + ":" + urlPath;
    String roleAuthKey = String.format(AUTH_PATH_KEY, path);
    if (stringRedisTemplate.hasKey(roleAuthKey)) {
        return stringRedisTemplate.opsForSet().members(roleAuthKey);                
    }

    return null;
}

五、改进之处:

使用@RoleAuth注解的例子如下:

@RoleAuth(roleTypes = ["admin", "merchant"])
@PutMapping("/user/action/user-update")
public Response<Boolean> userUpdate(@RequestBody UserUpdateDTO updateData) {
	//..............
}

这种使用注解的方式比硬编码的方式优雅的多,但是一旦后续需要调整接口角色权限的话,那么就必须修改代码并重新编译和部署,为解决这个问题,可以将接口的角色权限配置到数据库中,当修改了接口的角色权限以后去实时更新和生效。以数据库驱动的方式则显得更灵活。