一、实现流程:
系统中存在多种用户角色,比如说超级管理员、代理商、门店、服务商、终端用户等角色,每种角色有不同的权限,可以在接口层面去控制,比如某些接口只允许服务商使用,某些接口只允许终端用户使用等等,如果将权限控制硬编码在接口实现部分,会导致代码植入性太强,扩展性和可阅读性差,如果我们在接口上添加一个注解,指定接口需要哪些角色的权限才能执行,这种方式则显得更优雅。那么该如何实现通过注解来控制接口的角色权限了?注解只是定义了接口允许哪些角色执行,必须要有对注解的解析和判断控制才能实现接口的权限控制,我的实现思路如下:
1、定义注解,通过一个注解属性指定需要的权限集合;
2、在Spring容器加载完所有的bean之后,遍历所有的controller,过滤出所有加了这个注解的方法,读出注解的属性并缓存到redis中(缓存时以接口URL路径为key,支持的角色集合为value);
3、在网关层校验时,根据接口请求头里面的userId在缓存查找其角色,根据接口请求URL路径在第二步缓存的数据里面查找接口允许的角色集合,判断这个角色集合是否包含userId的角色,如果包含则允许执行,否则抛出权限拒绝的异常。
二、定义注解:
less
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RoleAuth {
/**
* 角色类型
*
* @return
*/
String[] roleTypes();
}
三、加载权限角色并缓存:
权限角色加载的时机必须是bean容器加载完成之后,恰好SpringBoot提供了一个扩展接口CommandLineRunner,这个接口只有一个run方法,其调用时机就是在所有bean加载完成之后调用:
scss
//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:
ini
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;
}
}
四、权限角色校验:
权限角色校验一般放在网关统一做校验,在网关上校验的代码如下:
ini
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注解的例子如下:
less
@RoleAuth(roleTypes = ["admin", "merchant"])
@PutMapping("/user/action/user-update")
public Response<Boolean> userUpdate(@RequestBody UserUpdateDTO updateData) {
//..............
}
这种使用注解的方式比硬编码的方式优雅的多,但是一旦后续需要调整接口角色权限的话,那么就必须修改代码并重新编译和部署,为解决这个问题,可以将接口的角色权限配置到数据库中,当修改了接口的角色权限以后去实时更新和生效。以数据库驱动的方式则显得更灵活。