Bootstrap

spring boot 3.x版本中集成spring security 6.x版本进行实现动态权限控制解决方案

一、背景

最近在进行项目从jdk8spring boot 2.7.x版本技术架构向jdk17spring boot 3.3.x版本的代码迁移,在迁移过程中,发现spring boot 3.3.x版本依赖的spring security版本已经升级6.x版本了,语法上和spring security 5.x版本有很多地方不兼容,因此记录试一下spring boot 3.3.x版本下,spring security 6.x的集成方案。

二、技术实现

1. 创建spring boot 3.3.x版本项目

spring boot 3.3.x版本对jdk版本要求较高,我这里使用的是jdk17,不久前,jdk21也已经发布了,可以支持虚拟线程,大家也可以使用jdk21

设置好jdk版本以后,新建项目,导入项目需要的相关依赖:

<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>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.1</version>
  </parent>

  <groupId>com.j.ss</groupId>
  <artifactId>spring-secrity6-spring-boot3-demo</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>spring-secrity6-spring-boot3-demo</name>
  <url>http://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>
  </dependencies>
</project>

2. 创建两个测试接口

  • 创建两个接口用于测试,源码参考如下

    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class SecurityController {
    
        @GetMapping("/hello")
        public String hello() {
            return "hello, spring security.";
        }
    
    
        @PostMapping("/work")
        public String work() {
            return "I am working.";
        }
    
    }
    
  • 启动项目,测试一下接口是否正常

    • hello接口

    • work接口
      在这里插入图片描述

3. 引入spring-boot-starter-security依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

引入spring-boot-starter-security依赖以后,此时访问接口,会有未授权问题。

在这里插入图片描述

4. 定义UserDetailsManager实现类

spring security框架会自动使用UserDetailsManagerloadUserByUsername方法进行用户加载,在加载用户以后,会在UsernamePasswordAuthenticationFilter过滤器中的attemptAuthentication方法中,进行前端输入的用户信息和加载的用户信息进行信息对比。

import lombok.extern.java.Log;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
@Log
public class MyUserDetailsManager implements UserDetailsManager {


    @Override
    public void createUser(UserDetails user) {

    }

    @Override
    public void updateUser(UserDetails user) {

    }

    @Override
    public void deleteUser(String username) {

    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {

    }

    @Override
    public boolean userExists(String username) {
        return false;
    }


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        /**
         * 这里为了演示方便,模拟从数据库查询,直接设置一下权限
         */
        log.info("query user from db!");
        return queryFromDB(username);
    }

    private static UserDetails queryFromDB(String username) {
        GrantedAuthority authority = new SimpleGrantedAuthority("testRole");
        List<GrantedAuthority> list = new ArrayList<>();
        list.add(authority);
        return new User("jack", // 用户名称
                new BCryptPasswordEncoder().encode("123456"), //密码
                list      //权限列表
        );
    }
}

5. 定义权限不足处理逻辑

用户在访问没有权限的接口时,会抛出异常,spring security允许我们自己这里这种异常,我这里就是模拟一下权限不足的提示信息,不做过多处理。

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.PrintWriter;

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //登陆状态下,权限不足执行该方法
        response.setStatus(200);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        PrintWriter printWriter = response.getWriter();
        String body = "403,权限不足!";
        printWriter.write(body);
        printWriter.flush();
    }
}

6. 定义未登录情况处理逻辑

当用户没有登录情况下,访问需要权限的接口时,会抛出异常,spring security允许我们自定义处理逻辑,这里未登录就直接抛出401,提示用户登录。

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;

@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        //验证为未登陆状态会进入此方法,认证错误
        response.setStatus(401);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        PrintWriter printWriter = response.getWriter();
        String body = "401, 请先进行登录!";
        printWriter.write(body);
        printWriter.flush();
    }
}

7. 定义自定义动态权限检验处理逻辑

在请求接口进行安全访问的时候,我们可以指定访问接口需要的角色,但是实际应用中,为了满足系统的灵活性,我们往往需要自定义动态权限的校验逻辑。

import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.function.Supplier;

@Component
public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
    /**
     * @param authentication the {@link Supplier} of the {@link Authentication} to check
     * @param object         the {@link T} object to check
     * @return
     */
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        // 获取访问url
        String requestURI = object.getRequest().getRequestURI();

        // 模拟从数据库或者缓存里面查询拥有当前URI的权限的角色
        String[] allRole = query(requestURI);

        // 获取当前用户权限
        Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();

        // 判断是否拥有权限
        for (String role : allRole) {
            for (GrantedAuthority r : authorities) {
                if (role.equals(r.getAuthority())) {
                    return new AuthorizationDecision(true); // 返回有权限
                }
            }
        }

        return new AuthorizationDecision(false); //返回没有权限
    }

    /**
     * 查询当前拥有对应url的权限的角色
     *
     * @param requestURI
     * @return
     */
    private String[] query(String requestURI) {
        return new String[]{"testRole"};
    }
}

8. 定义安全访问统一入口

在统一入口,我们可以做一些统一的逻辑,比如前后端分离的情况下,进行token内容的解析,这里我只是用代码模拟演示一下,方便大家理解。

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.java.Log;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;

@Component
@Log
public class MyAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("token"); // 前后端分离的时候获取token
        if (StringUtils.hasText(token)) { // 如果token不为空,则需要解析出用户信息,填充到当前上下文中
            UsernamePasswordAuthenticationToken authentication = getUserFromToken(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            if (log.isLoggable(Level.INFO)) {
                log.info("set authentication");
            }
        } else {
            if (log.isLoggable(Level.INFO)) {
                log.info("user info is null.");
            }
        }

        filterChain.doFilter(request, response);
    }

    private UsernamePasswordAuthenticationToken getUserFromToken(String token) {
        GrantedAuthority authority = new SimpleGrantedAuthority(token);
        List<GrantedAuthority> list = new ArrayList<>();
        list.add(authority);
        User user = new User("jack", // 用户名称
                new BCryptPasswordEncoder().encode("123456"), //密码
                list      //权限列表
        );
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
        usernamePasswordAuthenticationToken.setDetails(user);
        return usernamePasswordAuthenticationToken;
    }
}

9. 编写spring security配置类

当所有准备工作,做好以后,下面就是编写spring security的配置类了,使我们的相关配置生效。

import com.j.ss.MyAccessDeniedHandler;
import com.j.ss.MyAuthenticationEntryPoint;
import com.j.ss.MyAuthenticationFilter;
import com.j.ss.MyAuthorizationManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @Configuration 注解表示将该类以配置类的方式注册到spring容器中
 */
@Configuration
/**
 * @EnableWebSecurity 注解表示启动spring security
 */
@EnableWebSecurity
/**
 * @EnableMethodSecurity 注解表示启动全局函数权限
 */
@EnableMethodSecurity
public class WebSecurityConfig {

    /**
     * 权限不足处理逻辑
     */
    @Autowired
    private MyAccessDeniedHandler accessDeniedHandler;

    /**
     * 未授权处理逻辑
     */
    @Autowired
    private MyAuthenticationEntryPoint authenticationEntryPoint;

    /**
     * 访问统一处理器
     */
    @Autowired
    private MyAuthenticationFilter authenticationTokenFilter;

    /**
     * 自定义权限校验逻辑
     */
    @Autowired
    private MyAuthorizationManager myAuthorizationManager;

    /**
     * spring security的核心过滤器链
     *
     * @param httpSecurity
     * @return
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        // 定义安全请求拦截规则
        httpSecurity.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
                    authorizationManagerRequestMatcherRegistry
                            .requestMatchers("/hello")
                            .permitAll() // hello 接口放行,不进行权限校验
                            .anyRequest()
                            // .hasRole() 其他接口不进行role具体校验,进行动态权限校验
                            .access(myAuthorizationManager); // 动态权限校验逻辑
                })
                // 前后端分离,关闭csrf
                .csrf(AbstractHttpConfigurer::disable)
                // 前后端分离架构禁用session
                .sessionManagement(httpSecuritySessionManagementConfigurer -> {
                    httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
                // 访问异常处理
                .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> {
                    httpSecurityExceptionHandlingConfigurer.accessDeniedHandler(accessDeniedHandler);
                })
                // 未授权异常处理
                .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> {
                    httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(authenticationEntryPoint);
                })
                .headers(httpSecurityHeadersConfigurer -> {
                    // 禁用缓存
                    httpSecurityHeadersConfigurer.cacheControl(HeadersConfigurer.CacheControlConfig::disable);
                    httpSecurityHeadersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable);
                });
        // 添加入口filter, 前后端分离的时候,可以进行token解析操作
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }

    /**
     * 明文密码加密
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 忽略权限校验
     *
     * @return
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring().requestMatchers("/hello"));
    }

}

三、 功能测试

上述代码编写完成以后,启动项目,下面进行功能测试。

1. 忽略权限校验测试

访问/hello接口

在这里插入图片描述

可以看到,此时接口在无登录信息的情况下,也可以正常访问的。

2. 无权限测试

同样的,我们直接访问/work接口

在这里插入图片描述

可以看到,此时提醒我们需要登录了。

3. 有权限测试

再次访问/work接口,模拟已经登录,并拥有对应的权限。

在这里插入图片描述

可以看到,我们模拟有testRole权限,此时访问是正常的。

4. 权限不足测试

再次访问/work接口,模拟已经登录,但拥有错误的权限。

在这里插入图片描述

可以看到,此时报出了权限不足的异常。

四、写在最后

上面的案例只是演示,spring security的实际应用,应该根据具体项目权限要求来进行合理实现。

;