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简陋等问题,请大家谅解。

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

相关推荐
菜鸟起航ing1 小时前
【Java面试系列】Spring Boot中自动配置原理与自定义Starter开发实践详解 - 3-5年Java开发必备知识
java·spring boot·面试·自动配置·自定义starter
来自星星的坤4 小时前
Spring Boot 邮件发送配置遇到的坑:解决 JavaMailSenderImpl 未找到的错误
java·开发语言·spring boot·后端·spring
梦想实现家_Z4 小时前
Spring Boot整合DeepSeek+MCP实践详解
spring boot·deepseek·mcp
缘友一世5 小时前
解决Spring Boot上传默认限制文件大小和完善超限异常(若依框架)
java·spring boot·后端
小杨4045 小时前
springboot框架项目实践应用十七(springcloud整合nacos)
spring boot·后端·spring cloud
亭台烟雨中5 小时前
【Springboot后端之间使用websocket长连接通信】
spring boot·后端·websocket
XH2766 小时前
Android 使用 Vector Asset 用法详解
前端·设计
XH2766 小时前
Android TintList用法详解
前端·设计
XH2766 小时前
ColorStateList 用法详解
前端·设计
XH2766 小时前
Android 资源管理全解析:Color、String、Style、Dimen、Array
前端·设计