SpringBoot+SpringSecurity+Thymeleaf 基于 RBAC权限模型 开发系统权限模块 完成 URL/方法 鉴权

前言

最近在工作中开发的项目,是基于一个SpringBoot单体架构的权限管理系统模板来做的,项目整合了SpringSecurity来做登录鉴权,整合了Thymeleaf来完成页面展示。项目的权限模块,是基于RBAC权限模型来设计的,个人觉的还是很有学习的必要。

所以,我就自己开发了一个简单的权限管理系统,也是整合了SpringSecurity和Thymeleaf,主要是自己实现了一遍权限架构的设计、鉴权功能等等。也算是从工作中学到了东西。本文就来分享一下,权限架构的设计 以及一些功能细节。希望可以帮到大家 谢谢。

RBAC权限模型

先来介绍一下RBAC权限模型:

RBAC权限模型,全称Role-Based Access Control,是一种基于角色的权限访问控制。

这种机制特点,是它并不是将权限直接赋予用户,而是引入了角色概念,不同的角色赋予不同的权限,然后将角色绑定到用户。也就是说,它将用户按照角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。

RBAC简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种关联模式使得用户的授权管理变的简单和易于维护。

表结构

下面介绍一下表结构设计,主要就分享一下权限这块的表结构

根据上面的分析,要实现RBAC模型设计,我们需要提供三个概念:用户、角色、权限。那么至少需要用户表、角色表、权限表 三个表,而系统中的权限,主要体现为菜单权限和功能操作权限,所以权限表就分为菜单表功能操作表。然后还有用户与角色的绑定、以及角色与权限的绑定,还需要两个关联表:前者就叫角色用户关联表、后者就叫授权表。

综上,我们至少需要用户表、角色表、角色用户关联表、菜单表、功能操作表、授权表。下面看一下表结构:

用户表 t_user

角色表 t_role

角色用户关联表 t_role_user

菜单表 t_menu

功能操作表 t_operate

授权表 t_permission

上面的表结构还是比较简单的,有几个细节在这里要说明一下:

  1. 每个表都有一些通用的重复字段,例如create_time、create_user、逻辑删除字段is_delete等。
  2. 菜单表、功能操作表都有一个字段 权限表达式。这个字段其实就是自定义的一个字符串,可以为任意内容。代码中可以根据这个表达式来判断是否具备对应权限。
  3. 授权表中的字段可能稍微有点复杂。主要集中在后四个字段:
  4. obj_id就是要授权的对象的id,具体值要根据obj_type来判断,例如 如果obj_type是role,说明是为角色授权,obj_id就是角色id。org和user是考虑后面可能还会开发数据权限、以及针对单个用户的授权,这里先预留了类型,目前是只有角色授权。
  5. func_id就是具体授权的功能的id,具体值也要根据func_type来判断,例如 func_type为menu,那就是将某个菜单的权限赋予了角色,这里就填菜单的id;反之就是功能操作的id。

项目依赖

项目基于 SpringBoot v2.6.13,pom文件依赖如下:

xml 复制代码
<dependencies>
    <!--Spring Security-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!--thymeleaf-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!--thymeleaf整合springsecurity 可以在html页面中进行鉴权-->
    <dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    </dependency>
    <!--web场景启动器-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--mysql驱动-->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.17</version>
    </dependency>
    <!--mybatis-plus依赖-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.3.1</version>
    </dependency>
    <!--devtools 热部署工具 快捷键 ctrl+f9-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>
    <!--lombok-->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!--hutool工具包-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.26</version>
    </dependency>
    <!--参数校验依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!--引入aop-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <!--测试依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

因为项目引入了Mybatis-plus作为持久层框架,所以上面表设计中对应的Entity、Mapper、Service等代码已提前生成好了。

功能模块

登录

简单起见,登录功能就直接使用SpringSecurity默认的表单登录流程。我们要做的就是提供一些配置相关的代码、以及自定义了一个登录页面。

SpringSecurity配置类

首先要创建一个SpringSecurity的配置类,进行一些个性化配置。有一些细节需要注意一下:

  1. CustomUserDetailsService :这是一个自定义的用户数据获取的Service,它实现了UserDetailsServiceUserDetailsPasswordService接口,并重写了里面的方法。前者定义了loadUserByUsername方法,认证过程中会调用这个方法,根据username用户名获取用户数据;后者是一个密码升级需要用到的Service,里面定义了updatePassword方法,当用户的密码需要升级时,会调用这个方法完成密码升级。所谓密码升级就是将明文的密码进行加密。
  2. CustomAuthenticationSuccessHandler /CustomAuthenticationFailureHandler :这两个是自定义的认证成功&失败处理器。分别实现了AuthenticationSuccessHandler接口与AuthenticationFailureHandler接口,重写这两个接口的内定义的方法,可以自定义认证成功或失败后的执行逻辑。比方说:可以在登录成功之后,进行登录日志记录。
  3. MessageSource :这是Spring提供的国际化资源组件,SpringSecurity认证过程中,如果抛出异常,方会调用MessageSourceAccessorgetMessage方法,根据自定义的code,获取提前定义好的错误信息。这里将MessageSource配置之后,就可以在类路径下,定义i18n/messages.properties文件,将自定义的错误信息定义在里面。SpringSecurity就能获取到,并返回。
  4. CustomAuthenticationFilter :是一个自定义的过滤器,替换了默认的UsernamePasswordAuthenticationFilter,这里其实是自定义了登录时,用户名、密码以及其他信息(例如验证码)的获取逻辑。以及将上面提到的组件进行配置。
  5. 其他的方法都有详细的注释,可自行理解。上面提到的自定义组件的源码会贴在下面。
java 复制代码
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private MessageSource messageSource;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //设置用户数据源
        auth.userDetailsService(customUserDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //用自定义的Filter替代框架默认的UsernamePasswordAuthenticationFilter
        http.addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        //配置拦截规则
        http.authorizeRequests()
                //放行登录相关请求
                .antMatchers("/login/**").permitAll()
                //其余请求都需要认证
                .anyRequest().authenticated();

        //配置表单登录
        http.formLogin()
                .loginPage(LoginConstants.LOGIN_URL.getValue());

        //设置注销目录
        http.logout().logoutUrl("/logout");

        //关闭csrf防护
        http.csrf().disable();

        //解决iframe页面不能显示的问题
        http.headers().frameOptions().disable();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //放行静态资源
        web.ignoring().antMatchers("/static/**");
    }

    /**
     * 注册自定义登录过滤器
     *
     * @return
     */
    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
        //设置登录请求路径
        filter.setFilterProcessesUrl(LoginConstants.LOGIN_AUTH_URL.getValue());
        //提供认证管理器
        filter.setAuthenticationManager(authenticationManager());
        //自定义认证成功处理器
        filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        //自定义认证失败处理器
        filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
        //设置消息源
        filter.setMessageSource(messageSource);
        return filter;
    }

    /**
     * 注册认证管理器
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
}

认证成功&失败处理器

登录成功事件中,注入了一个事件发布器,自定义了一个登录成功事件,在登录成功之后进行了事件发布,这里是为了记录登录日志

java 复制代码
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //发布登录事件
        applicationEventPublisher.publishEvent(new LoginEvent(this, authentication));
        //跳转到首页
        response.sendRedirect("/");
    }
}

登录失败的处理器中,保存了错误信息到Session中,并重定向到了登录页面。

java 复制代码
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        //将错误信息保存到session
        request.getSession().setAttribute("error", exception.getMessage());
        //跳转到登录页
        response.sendRedirect("/login");
    }
}

自定义登录参数过滤器

这个过滤器的主要功能就是自定义登录参数的获取逻辑,如下,以后如果引入登录验证码,我可以在这里添加验证码的判断逻辑。

这个过滤器会在上面的配置类中,替换UsernamePasswordAuthenticationFilter,这样就可以实现自定义的逻辑。

java 复制代码
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        //判断是否为post请求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        //是否为json格式入参//校验验证码
        if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
            try {

//                //校验验证码
//                String captcha = "";
//                Object obj = request.getSession().getAttribute("captchaCode");
//                if (obj == null) throw new AuthenticationServiceException("验证码已过期!");
//                String captchaCode = obj.toString();
//                if (!captchaCode.equals(captcha) && !captchaCode.toLowerCase().equals(captcha) && !captchaCode.toUpperCase().equals(captcha)) {
//                    throw new AuthenticationServiceException("验证码输入错误!");
//                }
//                request.getSession().removeAttribute("captchaCode");

                //获取用户名密码 进行认证操作
                String username = obtainUsername(request);
                String password = obtainPassword(request);
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                        username, password);
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return super.attemptAuthentication(request, response);
    }
}

用户数据获取&密码升级Service

上面说到了,SpringSecurity需要一个获取用户数据的Service,认证过程中,用户数据就通过这个service来获取。在SpringSecurity中,这个组件被封装为了一个接口UserDetailsService,里面定义了一个方法loadUserByUsername,用于根据用户名获取用户信息。

SpringSecurity还存在密码升级的机制,如果框架检测到目前密码还是进行的明文存储,如果系统配置了密码升级的Service,那么系统会自动的进行密码升级,升级时修改数据库的操作就封装到了这个接口中:UserDetailsPasswordService,里面定义了一个方法updatePassword,实现类重写这个方法来完成密码修改。

项目中自定义了一个CustomUserDetailsService,实现了上述两个接口,重写了其中的方法,代码如下:

java 复制代码
@Component
public class CustomUserDetailsService implements UserDetailsService, UserDetailsPasswordService {

    @Autowired
    private UserService userService;

    @Autowired
    private PermissionService permissionService;

    /**
     * 根据username查询用户
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userService.getOne(new QueryWrapper<UserEntity>().eq("username", username));
        if (user == null) throw new UsernameNotFoundException("用户名不存在");
        //封装认证用户对象
        AuthUser authUser = new AuthUser();
        BeanUtils.copyProperties(user, authUser);
        //封装权限信息
        List<GrantedAuthority> authorities = permissionService.getPermissionExpressionByAuthUser(authUser.getId()).stream()
                .filter(pe -> StrUtil.isNotBlank(pe))
                .map(pe -> new SimpleGrantedAuthority(pe)).collect(Collectors.toList());
        authUser.setAuthorities(authorities);
        return authUser;
    }

    /**
     * 密码自动升级
     */
    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        Integer result = userService.updatePassword(user.getUsername(), newPassword);
        if (result == 1) ((AuthUser) user).setPassword(newPassword);
        return user;
    }
}

需要注意的是:在loadUserByUsername方法中,获取到用户信息之后,会将用户信息封装到另一个对象AuthUser中,这个对象将作为在SpringSecurity传递的用户信息,返回之前获取了当前用户所具备的所有权限表达式,将他们进行了封装。

在SpringSecurity中,用户信息的定义为UserDetails接口,所以AuthUser自然要实现这个接口,并重写方法。代码如下:

java 复制代码
@Data
public class AuthUser implements Serializable, UserDetails {
    private static final long serialVersionUID = 1L;

    /**
     * ID
     */
    private String id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 用户姓名
     */
    private String name;
    /**
     * 是否为管理员 管理员默认跳过所有权限判断
     */
    private Boolean isAdmin;

    /**
     * 是否禁用
     */
    private Boolean isDisabled;
    /**
     * 用户所具备的权限表达式集合
     */
    private List<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return !isDisabled;
    }
}

用户管理

用户管理,就是简单对用户表的增删改查,稍微需要注意一点的就是,用户管理中需要对是否为管理员这一项进行维护,因为系统的设计中,规定了管理员用户能够跳过所有鉴权。代码就不贴了,非常的简单。

菜单管理

菜单管理,因为菜单数据是一个树结构,所以我这里就用了一个树形组件,支持对菜单数据的增删改查。这里的菜单 可以定义一个权限表达式,来代表这个菜单的权限。

菜单管理除了基本的菜单数据的增删改查外,还支持在菜单下面,维护功能操作信息。这里所谓的功能操作,可以理解为就是菜单里面的按钮权限,当然,也可以用在其他的地方。只不过一般应用最多的就是按钮鉴权。

在引入了thymeleaf-extras-springsecurity5这个依赖之后,就可以直接在html页面中,通过sec:标签来进行按钮的显示控制,示例代码如下:

html 复制代码
<!DOCTYPE html>
<!--需要在最上面引入对应的命名空间信息-->
<html lang="en" xmlns:th="http://www.thymeleaf.org" 
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<!-- 中间代码省略... -->

<!--
在想要鉴权的标签上面 使用sec:authorize属性,
可以调用hasAuthority方法,传入权限表达式,就可以判断当前用户是否具备这个权限,
从而控制按钮是否展示
-->
<button sec:authorize="hasAuthority('menu::add')" class="layui-btn layui-btn-sm" onclick="openForm()">新增根菜单</button>

角色管理

角色管理本身也是一个简单的增删改查,与上面不同的是,这里又多了两个功能,权限分配授权用户

权限分配 :其实就是一个给角色授权的操作,这里设计的是打开表单,是一个树形结构,包含所有的菜单+功能操作数据,可以点击勾选。保存之后,角色就具备了这些已勾选的菜单权限和功能操作权限,后台实际上就是在授权表t_permission存入了一条给角色授权的数据。

授权用户 :这里其实就是维护了用户和角色的绑定关系,后台实际是存了角色用户关联表t_role_user

鉴权

截止到这里,就将整个权限模块的数据维护好了。下面要实现系统中的鉴权操作,这里简单的分为三类:

  1. 菜单鉴权:也就是主页查询菜单数据时,只把用户所绑定的菜单查询出来。
  2. 页面鉴权:当用户不通过菜单,直接访问页面时,如果当前用户没有权限,需要将其拦截,并返回一个权限不足的提示页面。这里应用了自定义切面。
  3. 方法鉴权:当绕过前端,直接调用后端的接口时,也需要在具体的方法上进行鉴权。这里使用的是SpringSecurity提供的相关API。

菜单鉴权

所谓菜单鉴权,其实就是查询用户菜单的时候,只查询出用户已绑定的菜单信息,这里提供一个sql,因个人sql水平有限,肯定不是最优解。也没开始考虑优化,仅供参考。

sql 复制代码
SELECT *
FROM t_menu
WHERE is_delete = 0
  AND is_disabled = 0
  AND EXISTS (SELECT *
              FROM t_permission
              WHERE is_delete = 0
                AND func_id = t_menu.id
                AND func_type = 'menu'
                AND obj_type = 'role'
                AND obj_id IN (SELECT role_id
                               FROM t_role_user
                               WHERE is_delete = 0 AND user_id = #{userId}))
ORDER BY sort;

这个sql是用于查询出所有有权限的菜单。代码中会再封装为树形结构,有需要的朋友可以参考文末给出的链接。

页面鉴权

页面鉴权,就是为了防止用户直接访问未授权的页面地址,如果不做权限控制,可能会访问到权限之外的资源。这里使用自定义切面,在页面跳转时,进行拦截鉴权。代码如下:

java 复制代码
@Aspect
@Component
public class PageAuthenticationAspect {

    @Autowired
    private MenuService menuService;

    @Before("@annotation(getMapping)")
    public void before(JoinPoint joinPoint, GetMapping getMapping) {
        //如果当前还没有认证 不需要鉴权(主要针对 /login)
        if (!AuthUtil.isAuthenticated()) {
            return;
        }
        AuthUser authUser = AuthUtil.getAuthUser();
        //如果当前登录用户为管理员 不需要鉴权
        if (authUser.getIsAdmin()) {
            return;
        }
        //判断 当前GetMapping 是否为页面跳转请求
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        //标注了GetMapping + 返回值类型为String + 没有标注@ResponseBody注解 = 页面跳转请求
        if (method.getReturnType() == String.class && method.getAnnotation(ResponseBody.class) == null &&
                method.getDeclaringClass().getAnnotation(ResponseBody.class) == null &&
                method.getDeclaringClass().getAnnotation(RestController.class) == null) {
            //判断当前访问路径是否在菜单管理中已配置,如果已配置 取出其权限表达式进行判断
            HttpServletRequest request = RequestUtil.getHttpServletRequestInstanceFromCurrentThread();
            MenuEntity menu = menuService.getOne(new QueryWrapper<MenuEntity>().eq("is_disabled", "0").eq("url", request.getRequestURI()));
            if (menu == null) return;
            List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) authUser.getAuthorities();
            boolean b = authorities.stream().anyMatch(authority -> authority.getAuthority().equals(menu.getPermissionExpression()));
            if (!b) throw new PageAuthenticationFailureException("您没有权限访问该页面!");
        }
    }
}

代码中注释很详细了,这里就不再赘述了。代码中用到了一个工具类AuthUtil,这是我自己封装的获取用户信息的工具类。

java 复制代码
public class AuthUtil {
    /**
     * 获取认证用户
     */
    public static AuthUser getAuthUser() {
        return ((AuthUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
    }

    /**
     * 当前是否已认证
     *
     * @return
     */
    public static Boolean isAuthenticated() {
        return !SecurityContextHolder.getContext().getAuthentication().isAuthenticated() ||
                !"anonymousUser".equals(SecurityContextHolder.getContext().getAuthentication().getPrincipal());
    }
}

还有一个自定义的异常类PageAuthenticationFailureException,这里是配合SpringBoot的全局异常拦截机制。

java 复制代码
public class PageAuthenticationFailureException extends RuntimeException{
    public PageAuthenticationFailureException(String message) {
        super(message);
    }
}

全局异常处理类

java 复制代码
@ControllerAdvice
public class GlobalExceptionHandler {
    
    //省略其它拦截方法...
    
    /**
     * 访问页面权限不足时的处理
     *
     * @param exception
     * @return
     */
    @ExceptionHandler(PageAuthenticationFailureException.class)
    public ModelAndView PageAuthenticationFailureExceptionHandler(PageAuthenticationFailureException exception) {
        return toError(HttpStatus.FORBIDDEN.value(), exception.getMessage());
    }

    /**
     * 跳转到错误页
     *
     * @param code
     * @param message
     * @return
     */
    private ModelAndView toError(String code, String message) {
        ModelAndView view = new ModelAndView("error");
        view.addObject("code", code);
        view.addObject("message", message);
        view.addObject("path", RequestUtil.getHttpServletRequestInstanceFromCurrentThread().getRequestURI());
        view.addObject("time", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
        return view;
    }
}

通用错误页面

html 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<th:block th:replace="common/common-import::header('通用错误页面')"></th:block>
<!--省略css...-->    
</head>
<body>
<div class="errDiv">
    <div class="errTitle"><h2>系统错误页面</h2></div>
    <div class="errContent">
        <table>
            <tr>
                <td>错误代码</td>
                <td th:text="${code}"></td>
            </tr>
            <tr>
                <td>错误描述</td>
                <td th:text="${message}"></td>
            </tr>
            <tr>
                <td>页面地址</td>
                <td th:text="${path}"></td>
            </tr>
            <tr>
                <td>发生时间</td>
                <td th:text="${time}"></td>
            </tr>
        </table>
    </div>
</div>
</body>
</html>

拦截效果

方法鉴权

方法鉴权就是避免用户直接访问后端接口跳过鉴权的机制,主要借助于SpringSecurity提供的API,以及全局异常处理来返回错误页面。

要实现这个功能,需要先开启配置,也就是SpringSecurity对于方法鉴权的配置。

首先需要提供一个配置类,需要标注@EnableGlobalMethodSecurity注解,还需要提供一个AccessDecisionManager,这是一个接口,是SpringSecurity中的决策器。它用来决定某个操作,是否有足够的权限。

java 复制代码
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true, jsr250Enabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Bean
    @Override
    protected AccessDecisionManager accessDecisionManager() {
        AbstractAccessDecisionManager decisionManager = (AbstractAccessDecisionManager) super.accessDecisionManager();
        List<AccessDecisionVoter<?>> decisionVoters = decisionManager.getDecisionVoters();
        decisionVoters.add(new CustomAccessDecisionVoter());//添加自定义投票器,对方法上的权限表达式有效
        return new AffirmativeBased(decisionVoters);//自定义权限决策管理器
    }
}

上面的代码中,使用了一个CustomAccessDecisionVoter,这是一个投票器对象。投票器也是SpringSecurity中提供的一个组件,是以接口形式定义的:AccessDecisionVoter,接口中存在一个vote方法,也就是具体的投票方法,返回值为int类型,1为通过,0为弃权,-1为拒绝。

这里的投票器进行投票操作之后,具体是否通过,要交由前面提到的决策器来决定。

自定义投票器的代码如下:

java 复制代码
public class CustomAccessDecisionVoter implements AccessDecisionVoter<Object> {
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute.getAttribute() != null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }

    @Override
    public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
        //如果认证信息为空 拒绝访问
        if (authentication == null) {
            return ACCESS_DENIED;
        }

        Object principal = authentication.getPrincipal();
        //如果为管理员角色,允许访问
        if (principal instanceof AuthUser && ((AuthUser) principal).getIsAdmin()) {
            return ACCESS_GRANTED;
        }

        //遍历用户的权限表达式列表,进行比对
        int result = ACCESS_ABSTAIN;
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (ConfigAttribute attribute : attributes) {
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;
                for (GrantedAuthority authority : authorities) {
                    if (attribute.getAttribute().equals(authority.getAuthority())) {
                        return ACCESS_GRANTED;
                    }
                }
            }
        }
        return result;
    }
}

如果请求被拦截了,抛出的是AccessDeniedException异常,所以还要再全局异常处理类中再加一个方法:

java 复制代码
@ExceptionHandler(AccessDeniedException.class)
public Object handleAccessDeniedException(AccessDeniedException exception) {
    return toError(HttpStatus.FORBIDDEN.value(), "权限不足");
}

拦截效果

总结

以上,就完成了一个简单的权限模块的构建。构建完毕后,支持灵活的权限控制,权限控制支持菜单控制、url控制、方法控制,最精细可以到按钮级别。因为这个项目只是个人学习权限设计做的,难免出现一些代码不规范,UI简陋等问题,请大家谅解。

如果有兴趣的朋友,可以 点击这里 下载项目,希望可以帮到你,谢谢阅读。

相关推荐
@Zeal24 分钟前
day01:项目概述,环境搭建
spring boot·jwt·lombok
kinlon.liu28 分钟前
基于 Nginx + Spring Boot + Vue + JPA 的网站安全防护指南
网络·vue.js·spring boot·nginx·安全
码农小野1 小时前
基于Vue.js和SpringBoot的地方美食分享网站系统设计与实现
vue.js·spring boot·美食
sealaugh321 小时前
spring boot(学习笔记第十二课)
spring boot·笔记·学习
FREE技术2 小时前
基于java+springboot+vue实现的畅销图书推荐系统(文末源码+lw+ppt)23-500
java·vue.js·spring boot
Jinyi5033 小时前
Spring Boot 高级配置:如何轻松定义和读取自定义配置
java·spring boot·后端·spring·java-ee·maven·intellij-idea
虫小宝3 小时前
Spring Boot中的API文档生成
java·spring boot·后端
虫小宝5 小时前
在Spring Boot中实现多线程任务调度
java·spring boot·spring
多多*14 小时前
每天一道面试题之浅浅讲一下java5中的自动装箱和自动拆箱
java·开发语言·spring boot·后端·spring
hummhumm15 小时前
数据结构第3节: 抽象数据类型
数据结构·spring boot·spring·spring cloud·java-ee·maven·intellij-idea