使用【注解】加【拦截器】实现权限控制

前面介绍了使用 SpringSecurity 进行权限控制,其中一个非常方便的特点就是:可以在类和方法上使用注解,从而实现对资源访问的权限控制。但是 Spring Security 具有一定的学习成本和复杂度,想要灵活驾驭并用好框架并非一件容易的事情,比如跟其它系统进行单点登录集成等等。

本篇博客介绍注解加拦截器的自定义权限控制方案,所有代码完全由自己掌控,而且实现了在在类和方法上使用注解实现对资源访问的权限控制。在绝大多数项目中,该方案完全可以满足需求。在本篇博客的最后会提供源代码下载。

需要注意的是:由于本篇博客只是进行 Demo 代码演示,所以使用了 Session 的方案。在实际项目中,可以参考该 Demo 进行方案改造,采用 Token 加 Redis 的方式进行用户信息存储,确保后端服务接口的无状态,从而能够满足负载均衡的需求。


一、搭建工程

搭建一个 SpringBoot 工程,其结构如下所示:

image

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 即可查看接口文档,通过其调试功能即可验证:

image

运行效果为:在没有运行成功登录接口之前,访问每个接口都会提示需要登录,登录之后访问相关接口,就能够验证权限。


本篇博客的源代码下载地址:https://files.cnblogs.com/files/blogs/699532/springboot_annotion1.zip

posted @ 2023-10-30 22:17  乔京飞  阅读(12026)  评论(0编辑  收藏  举报