前面介绍了注解加拦截器的权限控制方式,在拦截器中解析注解配置进行权限控制。拦截器的方案:优点是比较简洁,缺点是只能在 controller 及其下面的方法配置注解控制权限。已经可以满足绝大多数项目的需求。
本篇博客介绍第二种方案,在 AOP 切面中解析注解配置进行权限控制。AOP 方案,优点是可以通过配置切入点,在任何类以及下面的方法上配置注解控制权限,缺点是实现方案稍微繁琐一点点。
两种方案各有优缺点,主要根据自己项目的实际情况而定。当然也可以使用拦截器和 AOP 相结合的方案,这里就不展示了。
一、搭建工程
搭建一个 SpringBoot 工程,其结构如下所示:
CheckPower 是自定义的注解,用来配置访问资源所需要的权限信息
CheckPowerAspect 是自定义的 AOP 类,用于解析类和方法上配置的注解权限
CurrentUser 是自定义的 ThreadLocal 对象,主要用于在一次请求中共享存储信息,在本博客的 Demo 中主要用途为:每次请求都会经过 LoginCheckFilter 过滤器进行登录验证处理,如果用户已经登录,则将用户信息存储到 CurrentUser 中,在 AOP 处理类中可以从 CurrentUser 中获取到用户信息
Result 是自定义的返回结果类,统一使用该类的实例对象生成 json 返回给前端
WebMvcConfig 是对网站相关的配置,包括:静态资源放行、knife4j 接口文档配置
controller 下面的类都是对外提供的接口,我们会在这里的类和方法上使用注解配置访问权限
LoginCheckFilter 是自定义的过滤器,用于拦截用户的请求,验证用户是否登录
先看一下 pom 文件引入的依赖包:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jobs</groupId>
<artifactId>springboot_annotion2</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--引入 AOP 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!--引入 knife4j 依赖,使用接口文档的调试功能进行测试验证-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!--里面有很多非常实用的工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.4.5</version>
</plugin>
</plugins>
</build>
</project>
这里主要引入了 2 个关键的依赖包:spring-boot-starter-aop 和 knife4j-spring-boot-starter。本 Demo 仍然不使用相关的 web 页面进行测试,相比上一篇博客的 Demo 而言,application.yml 和 Result 类没有任何变化,内容如下:
server:
port: 8888
servlet:
session:
# 设置 session 有效期为 10 分钟
timeout: 10m
knife4j:
# 是否启用增强版功能
enable: true
# 如果是生产环境,将此设置为 true,然后就能够禁用了 knife4j 的页面
production: false
# 自定义的用户配置
user:
username: jobs
password: 123
powerlist: delorder,adduser,admin
package com.jobs.common;
import lombok.Data;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
@Data
public class Result<T> implements Serializable {
//状态码
private Integer status;
//消息
private String msg;
//返回的数据
private T data;
public static <T> Result<T> success(T object) {
Result<T> r = new Result<T>();
r.status = 0;
r.msg = "success";
r.data = object;
return r;
}
public static <T> Result<T> fail(Integer status, String msg) {
Result r = new Result();
r.status = status;
r.msg = msg;
return r;
}
public static <T> Result<T> error(String msg) {
Result r = new Result();
r.status = 500;
r.msg = msg;
return r;
}
}
下面列出自定义的 ThreadLocal 类 CurrentUser,用于在一次请求中所经过的所有方法中共享信息
package com.jobs.common;
//基于 ThreadLocal 封装的工具类,可以在一次请求过程中,在各个方法中共享访问
public class CurrentUser {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void setCurrentUser(String user) {
threadLocal.set(user);
}
public static String getCurrentUser() {
return threadLocal.get();
}
}
二、过滤器、注解、AOP
对于 SpringBoot 来说,使用过滤器主要有 2 个步骤:
首先在启动类上,添加 @ServletComponentScan 注解,因为过滤器是基于 Servlet 实现的
package com.jobs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@Slf4j
@ServletComponentScan
@SpringBootApplication
public class MainApp {
public static void main(String[] args) {
SpringApplication.run(MainApp.class, args);
log.info("程序已经启动...");
}
}
然后创建自定义过滤器 LoginCheckFilter 即可,在过滤器上使用 @WebFilter 注解配置好过滤器的名称和要监听的请求地址,一版情况下我们都会监听所有的请求地址。如果编写了多个过滤器的话,对于使用注解配置的过滤器,无法配置多个过滤器的执行优先级,绝大多数情况下,我们只需要一个过滤器。
package com.jobs.filter;
import com.alibaba.fastjson.JSON;
import com.jobs.common.CurrentUser;
import com.jobs.common.Result;
import org.springframework.util.AntPathMatcher;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
//执行顺序:过滤器 > 拦截器 > AOP
//判断用户是否登录,这里使用过滤器,具有最高执行的优先级
@WebFilter(filterName = "LoginCheckFilter", urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//放行不需要登录就可以使用的 url 路径
String requestURI = request.getRequestURI();
if (checkPassUri(requestURI)) {
filterChain.doFilter(request, response);
return;
}
//如果 session 存在,则表明已经登录过了
Object user = request.getSession().getAttribute("user");
if (user != null) {
CurrentUser.setCurrentUser(user.toString());
filterChain.doFilter(request, response);
return;
}
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(JSON.toJSONString(Result.fail(-99, "请登录后再访问")));
}
private static final AntPathMatcher apm = new AntPathMatcher();
private boolean checkPassUri(String requestURI) {
String[] uris = new String[]{
//放行用户登录接口
"/user/login",
//放行用户退出接口
"/user/logout",
//放行下面的 knifefj 的静态资源文件路径
"/doc.html",
"/webjars/**",
"/swagger-resources",
"/v2/api-docs"
};
for (String uri : uris) {
boolean match = apm.match(uri, requestURI);
if (match) {
return true;
}
}
return false;
}
}
我们自定义的 @CheckPower 注解,相比上篇博客的 Demo 没有任何变化,内容如下:
package com.jobs.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPower {
//需要具备的权限列表,默认情况下数据组只有一个空字符串元素,表示登录后不需要验证权限就可以访问
String[] power() default "";
//如果配置了多个权限,之间是 and 关系,还是 or 关系
//如果是 and 关系,则表示登录的用户,必须同时具备所配置的多个权限,才能访问
//如果是 or 关系,则表示登录的用户,只要具备所配置的权限列表中的任意一个权限,就可以访问
String loggic() default "or";
}
我们自定义的 AOP 切面类 CheckPowerAspect 监听 controller 包下的所有 public 方法,因此在对外提供的 controller 类中,如果不是对外提供的接口方法,则建议使用其他修饰符(如:protected 或 private)
package com.jobs.aspect;
import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.jobs.annotation.CheckPower;
import com.jobs.common.CurrentUser;
import com.jobs.common.Result;
import org.apache.logging.log4j.util.Strings;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import javax.el.TypeConverter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
//执行顺序:过滤器 > 拦截器 > AOP
//这里使用 AOP,用户请求过来之后,先执行过滤器判断用户是否登录
//如果用户登录后,再执行 AOP 判断用户是否具有执行方法的权限
@Aspect
@Component
public class CheckPowerAspect {
//监控 com.jobs.controller 包以及其所有子包中的 public 方法
@Pointcut("execution(public * com.jobs.controller..*(..))")
public void pt() {
//空方法,目的是为了挂载切入点
}
@Around("pt()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
//实现逻辑:
//类上有 CheckPower 注解的话,就验证是否登录,然后验证类和方法的权限;
//类上没有 CheckPower 注解的话,就什么都不验证,直接执行原始方法
//获取【类】上的注解,看是否存在 CheckPower 注解
CheckPower cpClass = pjp.getTarget().getClass().getAnnotation(CheckPower.class);
if (cpClass != null) {
System.out.println("访问的【类】上有 @CheckPower 注解,开始验证权限...");
//获取用户信息
Object user = CurrentUser.getCurrentUser();
if (user == null) {
//用户需要登录,才能获取到用户的权限,才能去验证权限是否满足
return Result.fail(-99, "请登录后再访问");
}
//获取用户具有的权限
JSONObject userMap = JSON.parseObject(user.toString());
JSONArray jsonArray = userMap.getJSONArray("powerlist");
String[] powerlist = jsonArray.toArray(new String[jsonArray.size()]);
//【类】上配置的权限是否满足,默认为 true 表示满足
boolean classCheckPowerFlag = true;
//获取配置的权限列表
System.out.println("【类】需要的权限为:" + Arrays.toString(cpClass.power()) +
",权限逻辑关系:" + cpClass.loggic());
System.out.println("用户具有的权限:" + Arrays.toString(powerlist));
//如果【类】上有 @CheckPower 注解,并且没有配置任何权限,则表示登录后可以随便访问
if (cpClass.power().length == 1 && cpClass.power()[0].equals("")) {
classCheckPowerFlag = true;
} else {
classCheckPowerFlag =
getCheckPowerResult(powerlist, cpClass.power(), cpClass.loggic());
}
//判断方法上是否有 @CheckPower 注解
CheckPower cpMethod =
((MethodSignature) pjp.getSignature()).getMethod().getAnnotation(CheckPower.class);
if (cpMethod != null) {
System.out.println("访问的 Method 方法上有 @CheckPower 注解...");
//获取配置的权限列表
System.out.println("Method 需要的权限为:" + Arrays.toString(cpMethod.power())
+ ",权限逻辑关系:" + cpMethod.loggic());
System.out.println("用户具有的权限:" + Arrays.toString(powerlist));
//如果方法上有 @CheckPower 注解,并且没有配置任何权限,则表示登录后可以随便访问
//之所以这样做,是为了能够在 Controller 上设置了权限后,对其下面的个别方法可以放开权限。
if (cpMethod.power().length == 1 && cpMethod.power()[0].equals("")) {
System.out.println("方法上 @CheckPower 没有配置任何权限," +
"表示不考虑 controller 上是否配置了权限,只要登录就可以任意访问");
Object ret = pjp.proceed();
return ret;
} else {
//方法上需要验证权限,此时先看【类】上的权限验证是否通过,通过后再考虑验证方法上的权限
if (classCheckPowerFlag) {
boolean methodCheckPowerFlag =
getCheckPowerResult(powerlist, cpMethod.power(), cpMethod.loggic());
if (methodCheckPowerFlag) {
Object ret = pjp.proceed();
return ret;
} else {
return Result.fail(-1, "没有权限访问");
}
} else {
return Result.fail(-1, "没有权限访问");
}
}
} else {
//如果方法上没有 @CheckPower 注解,则判断是否具有访问类的权限
if (classCheckPowerFlag) {
Object ret = pjp.proceed();
return ret;
} else {
return Result.fail(-1, "没有权限访问");
}
}
} else {
//类上面没有注解,则不验证权限,无论是否登录,都可以访问
Object ret = pjp.proceed();
return ret;
}
}
//判断用户的权限,是否满足所需要的访问权限
private Boolean getCheckPowerResult(String[] userPowerList, String[] checkPowerList, String loggic) {
if (loggic.equalsIgnoreCase("or")) {
// or 关系,只要用户具有的任意一个权限,在配置的权限列表中,就可以访问
return CollectionUtil.containsAny(Arrays.asList(userPowerList), Arrays.asList(checkPowerList));
} else if (loggic.equalsIgnoreCase("and")) {
// and 关系,要求用户的权限,必须包含所配置的权限列表
return CollectionUtil.containsAll(Arrays.asList(userPowerList), Arrays.asList(checkPowerList));
} else {
return false;
}
}
}
默认情况下,SpringBoot 只允许访问 resources 下面的 static 目录下的静态资源,由于使用了 knife4j 接口文档,因此需要对其资源进行放行,否则我们无法访问 knife4j 的接口文档,具体代码如下:
package com.jobs.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.oas.annotations.EnableOpenApi;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
@EnableOpenApi
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
//设置静态资源目录,以及访问地址映射,这里放行 knife4j 文档的访问地址
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/doc.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
@Bean
public Docket createRestApi() {
// 文档类型
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.jobs.controller"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("我的测试")
.version("1.0")
.description("【注解+AOP+过滤器】的权限控制测试")
.build();
}
}
三、用于测试的接口
需要注意的是:本 Demo 在 CheckPowerAspect 对权限验证的逻辑,与上篇博客的验证逻辑有一点小变化
-
要求在 controller 类上使用 @CheckPower 注解后,才能对其下面的方法使用 @CheckPower 注解控制权限。如果仅仅只是在方法上添加 @CheckPower 注解,是不会对方法进行任何权限控制的。
-
如果类上配置了 @CheckPower 注解,但是没有配置任何权限参数,则只会验证用户是否登录,不验证类的访问权限。
-
如果方法上配置 @CheckPower 注解,但是没有配置任何权限参数,则表示只要登录了就可以访问该方法,不考虑 controller 类上是否配置了权限。该验证逻辑主要是为了满足某些特殊场景的需求。
UserController 不需要进行权限控制,所以没有在其类上添加 @CheckPower 注解。其它的 controller 类或方法需要进行权限控制,因此其 controller 类上必须添加 @CheckPower 注解。
UserController 主要实现用户的登录和退出,在过滤器中已经放行,可以随便访问:
package com.jobs.controller;
import com.alibaba.fastjson.JSON;
import com.jobs.common.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Api(tags = "用户操作相关接口")
@RequestMapping("/user")
@RestController
public class UserController {
@Value("${user.username}")
private String username;
@Value("${user.password}")
private String passoword;
//对于以英文逗号分隔的字符串配置,可以自动转换为数组
@Value("${user.powerlist}")
private String[] powerlist;
@ApiOperation("用户登录")
@ApiImplicitParams({
@ApiImplicitParam(name = "name", value = "用户名", required = true),
@ApiImplicitParam(name = "pwd", value = "密码", required = true)
})
@PostMapping("/login")
public Result<String> login(String name, String pwd, HttpServletRequest request) {
if (username.equals(name) && passoword.equals(pwd)) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("username", username);
userMap.put("powerlist", powerlist);
String json = JSON.toJSONString(userMap);
request.getSession().setAttribute("user", json);
return Result.success("登录成功");
} else {
return Result.fail(-1, "用户名或密码不正确");
}
}
@ApiOperation("用户退出")
@PostMapping("/logout")
public Result<String> logout(HttpServletRequest request) {
request.getSession().removeAttribute("user");
return Result.success("退出成功");
}
}
Test1Controller 用于测试在方法上使用注解配置权限的场景:
package com.jobs.controller;
import com.jobs.annotation.CheckPower;
import com.jobs.common.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@CheckPower
@Api(tags = "test1方法权限测试")
@RequestMapping("/test1")
@RestController
public class Test1Controller {
@ApiOperation("添加订单测试")
@GetMapping("/addorder")
//只要用户具有 addorder 权限,就可以访问
@CheckPower(power = "addorder")
public Result addorder() {
return Result.success("hello addorder 访问成功");
}
@ApiOperation("删除订单测试")
@GetMapping("/delorder")
//用户具有 root 或 delorder 任意一个权限,就可以访问
//由于 loggic 的默认值就是 or ,所以可以省略不写
@CheckPower(power = {"root", "delorder"}, loggic = "or")
public Result delorder() {
return Result.success("hello delorder 访问成功");
}
@ApiOperation("添加用户测试")
@GetMapping("/adduser")
//只要用户具有 adduser 权限,就可以访问
@CheckPower(power = "adduser")
public Result adduser() {
return Result.success("hello adduser 访问成功");
}
@ApiOperation("删除用户测试")
@GetMapping("/deluser")
//用户具有 admin 或者 deluser 的任意权限,就可以访问
@CheckPower(power = {"admin", "deluser"}, loggic = "and")
public Result deluser() {
return Result.success("hello deluser 访问成功");
}
}
Test2Controller 用于同时测试在类和方法上使用注解配置权限的场景:
package com.jobs.controller;
import com.jobs.annotation.CheckPower;
import com.jobs.common.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
//在 controller 类上面添加了权限验证的注解
@CheckPower(power = "root")
@Api(tags = "test2类权限测试")
@RequestMapping("/test2")
@RestController
public class Test2Controller {
//在该方法上添加了 @CheckPower 注解,但是没有配置任何权限
//此时即使 controller 上配置了权限,并且验证不通过,该方法也可以不验证权限进行访问
@CheckPower
@ApiOperation("查看订单列表")
@GetMapping("/vieworder")
public Result vieworder() {
return Result.success("hello vieworder 访问成功");
}
//在方法上没有添加权限验证的注解
@ApiOperation("查看订单详情")
@GetMapping("/viewdetail")
public Result getdetail() {
return Result.success("hello viewdetail 访问成功");
}
}
Test3Controller 用于测试在类上使用注解配置访问权限的场景:
package com.jobs.controller;
import com.jobs.annotation.CheckPower;
import com.jobs.common.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
//在 controller 类上面添加了权限验证的注解
@CheckPower(power = "admin")
@Api(tags = "test3类上的权限测试")
@RequestMapping("/test3")
@RestController
public class Test3Controller {
//在方法上没有添加权限验证的注解
@ApiOperation("查看用户测试")
@GetMapping("/viewuser")
public Result viewuser() {
return Result.success("hello viewuser 访问成功");
}
}
最后运行 SpringBoot 工程,访问 http://localhost:8888/doc.html
即可查看接口文档,通过其调试功能即可验证:
运行效果为:在没有运行成功登录接口之前,访问每个接口都会提示需要登录,登录之后访问相关接口,就能够验证权限。
本篇博客的源代码下载地址:https://files.cnblogs.com/files/blogs/699532/springboot_annotion2.zip