使用【注解】加【拦截器】实现权限控制
前面介绍了使用 SpringSecurity 进行权限控制,其中一个非常方便的特点就是:可以在类和方法上使用注解,从而实现对资源访问的权限控制。但是 Spring Security 具有一定的学习成本和复杂度,想要灵活驾驭并用好框架并非一件容易的事情,比如跟其它系统进行单点登录集成等等。
本篇博客介绍注解加拦截器的自定义权限控制方案,所有代码完全由自己掌控,而且实现了在在类和方法上使用注解实现对资源访问的权限控制。在绝大多数项目中,该方案完全可以满足需求。在本篇博客的最后会提供源代码下载。
需要注意的是:由于本篇博客只是进行 Demo 代码演示,所以使用了 Session 的方案。在实际项目中,可以参考该 Demo 进行方案改造,采用 Token 加 Redis 的方式进行用户信息存储,确保后端服务接口的无状态,从而能够满足负载均衡的需求。
一、搭建工程
搭建一个 SpringBoot 工程,其结构如下所示:
CheckPower 是自定义的注解,用来配置访问资源所需要的权限信息
Result 是自定义的返回结果类,统一使用该类的实例对象生成 json 返回给前端
WebMvcConfig 是对网站相关的配置,包括:拦截器配置、静态资源放行、knife4j 接口文档配置等
controller 下面的类都是对外提供的接口,我们会在这里的类和方法上使用注解配置访问权限
CheckPowerInterceptor 是自定义的拦截器,用于对 controller 类和方法上的注解进行解析,控制用户访问权限
先看一下 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_annotion1</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>
<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>
我们不使用相关的 web 页面进行测试,这里引入 knife4j 依赖包,使用接口文档进行接口测试,非常方便。
然后看一下 application.yml 文件内容,我们没有连接数据库去验证用户登录,这里直接配置了测试用户信息:
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
最后再看一下 Result 类,统一使用该类的实例对象生成 json 返回给前端:
package com.jobs.common;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@ApiModel("返回结果")
@Data
public class Result<T> implements Serializable {
@ApiModelProperty("状态码")
private Integer status;
@ApiModelProperty("状态消息")
private String msg;
@ApiModelProperty("返回的数据")
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;
}
}
二、注解和拦截器
创建自定义注解 CheckPower ,可以配置在 controller 类以及其内部的方法上,其内容如下:
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";
}
power 属性是个数组,可以为资源配置多个访问权限,其默认值是空字符串。如果没有对 power 属性进行配置,power 属性就是拥有一个空字符串元素的数组。
loggic 属性表示 power 数组中配置的多个权限值的验证关系,如果是 and 表示需要用户必须同时拥有 power 数组中配置的所有权限值才可以访问资源,如果是 or 表示只要用户只需要拥有 power 数组中任意一个权限值就可以访问资源。
下面列出拦截器的内容,其功能就是解析 controller 以及其内部的方法上的 CheckPower 注解,通过比对用户本身的权限,判断用户是否满足资源所配置的权限,如果不满足则直接将没有访问权限的提示信息返回给前端。
package com.jobs.interceptor;
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.Result;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
//执行顺序:过滤器 > 拦截器 > AOP
//本 Demo 只使用了拦截器,进行 controloler 和 method 上的 @CheckPower 注解解析和权限判断
//本 Demo 没有使用到【过滤器】和【AOP】
@Component
public class CheckPowerInterceptor implements HandlerInterceptor {
//拦截请求,在请求执行前,执行该方法,
//这里判断 ontrololer 和 method 上是否有 @CheckPower 注解,
//如果有的话,则判断用户的权限,是否满足所设置的执行权限
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
//如果 session 存在,则表明已经登录过了
Object user = request.getSession().getAttribute("user");
if (user == null) {
//用户需要登录
ResposeResult(response, Result.fail(-99, "请登录后再访问"));
return false;
}
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
//获取用户具有的权限
JSONObject userMap = JSON.parseObject(user.toString());
JSONArray jsonArray = userMap.getJSONArray("powerlist");
String[] powerlist = jsonArray.toArray(new String[jsonArray.size()]);
// controller 上配置的权限是否满足,默认为 true 表示满足
boolean controllerCheckPowerFlag = true;
//获取类上的注解,看是否存在 CheckPower 注解
CheckPower cpClass = hm.getBeanType().getAnnotation(CheckPower.class);
if (cpClass != null) {
System.out.println("访问的 Controller 上有 @CheckPower 注解...");
//获取配置的权限列表
System.out.println("Controller 需要的权限为:" + Arrays.toString(cpClass.power()) +
",权限逻辑关系:" + cpClass.loggic());
System.out.println("用户具有的权限:" + Arrays.toString(powerlist));
//如果 controller 上有 @CheckPower 注解,并且没有配置任何权限,则表示登录后可以随便访问
if (cpClass.power().length == 1 && cpClass.power()[0].equals("")) {
controllerCheckPowerFlag = true;
} else {
controllerCheckPowerFlag =
getCheckPowerResult(powerlist, cpClass.power(), cpClass.loggic());
}
}
//先判断方法上是否有 @CheckPower 注解
if (hm.hasMethodAnnotation(CheckPower.class)) {
System.out.println("访问的 Method 方法上有 @CheckPower 注解...");
CheckPower cpMethod = hm.getMethodAnnotation(CheckPower.class);
//获取配置的权限列表
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 上是否配置了权限,只要登录就可以任意访问");
return true;
}
//方法上需要验证权限,此时先看 controler 上的权限验证是否通过,通过后再考虑验证方法上的权限
if (controllerCheckPowerFlag) {
boolean methodCheckPowerFlag =
getCheckPowerResult(powerlist, cpMethod.power(), cpMethod.loggic());
if (methodCheckPowerFlag == false) {
ResposeResult(response, Result.fail(-1, "没有权限访问"));
}
return methodCheckPowerFlag;
}
}
if (controllerCheckPowerFlag == false) {
ResposeResult(response, Result.fail(-1, "没有权限访问"));
}
return controllerCheckPowerFlag;
}
return true;
}
//判断用户的权限,是否满足所需要的访问权限
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;
}
}
//返回给前端 json 结果
private void ResposeResult(HttpServletResponse response, Result result) throws IOException {
response.setContentType("application/json;charset=utf-8");
String json = JSON.toJSONString(result);
response.getWriter().write(json);
}
}
这里也实现了一个逻辑:如果 controller 和其内部的一个方法资源(假设方法名称为 aaa)上,同时配置了 CheckPower 注解权限,此时如果方法(aaa)上配置的 CheckPower 权限列表为空,则不再考虑 controller 上配置的权限,用户只要登录了,就可以访问方法(aaa)。之所以实现这样的权限控制逻辑,主要是为了满足这样的场景:controller 中的绝大多数方法需要控制权限,只需要在 controller 类上加上注解即可,但是个别方法不需要控制权限,只需要为这些方法增加 @CheckPower 注解,但不进行 power 属性的配置即可。
最后我们需要对拦截器进行相关配置,以便其能够在 SpringBoot 中生效,拦截所有的请求,但是要放行一些资源,比如用户登录接口等等,具体要放行的资源,大家可以抽取出来,配置到 yml 文件中,这里为了方便就直接在代码中写死了。
package com.jobs.config;
import com.jobs.interceptor.CheckPowerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
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/");
}
//拦截器,拦截所有请求,一方面判断用户是否登录,
//另一方面判断类上是否有 @CheckPower 注解,如果有则判断当前登录的用户是否有权限
@Override
protected void addInterceptors(InterceptorRegistry registry) {
//添加拦截器(可以添加多个拦截器,拦截器的执行顺序,就是添加顺序)
CheckPowerInterceptor myInterceptor = new CheckPowerInterceptor();
//设置拦截器拦截的请求路径,以及不拦截的请求路径
registry.addInterceptor(myInterceptor).addPathPatterns("/**")
.excludePathPatterns(getExcludePathPatterns());
}
//在这里设置拦截器不需要进行拦截的路径
private String[] getExcludePathPatterns() {
String[] uris = new String[]{
//放行用户登录接口
"/user/login",
//放行用户退出接口
"/user/logout",
//放行下面的 knifefj 的静态资源文件路径
"/doc.html",
"/webjars/**",
"/swagger-resources",
"/v2/api-docs"
};
return uris;
}
@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("注解加拦截器的权限控制测试")
.build();
}
}
三、用于测试的接口
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.*;
@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;
@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_annotion1.zip