Spring Security 6 配置方法,废弃 WebSecurityConfigurerAdapter

目录
文章目录隐藏
  1. 前言
  2. 配置成功后根据配置的情况整理的类图
  3. 配置详情
  4. 结语

前言

最近阿里云的项目迁回本地运行,数据库从阿里云的 RDS(即 Mysql5.6)换成了本地 8.0,Redis 也从古董级别的 2.x 换成了现在 6,忍不住,手痒,把 jdk 升级到了 17,用 zgc 垃圾回收器,源代码重新编译重新发布,结果碰到了古董的 SpringBoot 不支持 jdk17,所以有了这篇日志。记录一下 SpringBoot2+SpringSecurity+JWT 升级成 SpringBoot3+SpringSecurity+JWT,就像文章标题所说的,SpringSecurity 已经废弃了继承 WebSecurityConfigurerAdapter 的配置方式,那就的从头来咯。

在 Spring Security 5.7.0-M2 中,Spring 就废弃了 WebSecurityConfigurerAdapter,因为 Spring 官方鼓励用户转向基于组件的安全配置。本文整理了一下新的配置方法。

在下面的例子中,我们使用 Spring Security lambda DSL 和 HttpSecurity#authorizeHttpRequests 方法来定义我们的授权规则,从而遵循最佳实践。

配置成功后根据配置的情况整理的类图

配置成功后根据配置的情况整理的类图

配置详情

1. 启用 WebSecurity 配置

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
}

以下的配置在 SecurityConfig 内完成。

1.1. 注册 SecurityFilterChain Bean,并完成 HttpSecurity 配置

HttpSecurity中注意使用了lambda写法,使用这种写法之后,每个设置都直接返回HttpSecurity对象,避免了多余的and()操作符。每一步具体的含义,请参考代码上的注释。

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 basic 明文验证
                .httpBasic().disable()
                // 前后端分离架构不需要 csrf 保护
                .csrf().disable()
                // 禁用默认登录页
                .formLogin().disable()
                // 禁用默认登出页
                .logout().disable()
                // 设置异常的 EntryPoint,如果不设置,默认使用 Http403ForbiddenEntryPoint
                .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(invalidAuthenticationEntryPoint))
                // 前后端分离是无状态的,不需要 session 了,直接禁用。
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        // 允许所有 OPTIONS 请求
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        // 允许直接访问授权登录接口
                        .requestMatchers(HttpMethod.POST, "/web/authenticate").permitAll()
                        // 允许 SpringMVC 的默认错误地址匿名访问
                        .requestMatchers("/error").permitAll()
                        // 其他所有接口必须有 Authority 信息,Authority 在登录成功后的 UserDetailsImpl 对象中默认设置“ROLE_USER”
                        //.requestMatchers("/**").hasAnyAuthority("ROLE_USER")
                        // 允许任意请求被已登录用户访问,不检查 Authority
                        .anyRequest().authenticated())
                .authenticationProvider(authenticationProvider())
                // 加我们自定义的过滤器,替代 UsernamePasswordAuthenticationFilter
                .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }

1.2. 注册自定义用户登录信息查询 Bean

    @Autowired
    private UserDetailsService userDetailsService;
 
    @Bean
    public UserDetailsService userDetailsService() {
        // 调用 JwtUserDetailService 实例执行实际校验
        return username -> userDetailsService.loadUserByUsername(username);
    }

这里关联到一个自定义的子类 UserDetailsService,代码逻辑如下,注意需要根据实际情况改造数据库查询逻辑:

@Component
public class SecurityUserDetailsService implements UserDetailsService {
 
    @Autowired
    private SqlSession sqlSession;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        try {
            // 查询数据库用户表,获得用户信息
            sqlSession.xxx
            // 使用获得的信息创建 SecurityUserDetails
            SecurityUserDetails user = new SecurityUserDetails(username, 
                    password,
                    // 以及其他 org.springframework.security.core.userdetails.UserDetails 接口要求的信息
                    );
 
            logger.info("用户信息:{}", user);
            return user;
        } catch (Exception e) {
            String msg = "Username: " + username + " not found";
            logger.error(msg, e);
            throw new UsernameNotFoundException(msg);
        }
    }
}

1.3. 注册密码加密 Bean

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

1.4. 注册用户授权检查执行 Bean

这里设定使用 DaoAuthenticationProvider 执行具体的校验检查,并且将自定义的用户登录查询服务 Bean,和密码生成器都注入到该对象中

    /**
     * 调用 loadUserByUsername 获得 UserDetail 信息,在 AbstractUserDetailsAuthenticationProvider 里执行用户状态检查
     *
     * @return
     */
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取 UserDetails
        authProvider.setUserDetailsService(userDetailsService());
        // 设置密码编辑器
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

1.5. 注册授权检查管理 Bean

    /**
     * 登录时需要调用 AuthenticationManager.authenticate 执行一次校验
     *
     * @param config
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

1.6. 注册每次请求的 jwt 检查拦截器

在拦截器中检查 jwt 是否能够通过签名验证,是否还在有效期内。如果通过验证,使用 jwt 中的信息生成一个不含密码信息的 SecurityUserDetails 对象,并设置到 SecurityContext 中,确保后续的过滤器检查能够知晓本次请求是被授权过的。具体代码逻辑看 3.2. jwt 请求过滤器。

    @Bean
    public JwtTokenOncePerRequestFilter authenticationJwtTokenFilter() {
        return new JwtTokenOncePerRequestFilter();
    }

1.7.  SecurityConfig 对象完整内容

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Autowired
    private InvalidAuthenticationEntryPoint invalidAuthenticationEntryPoint;
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public JwtTokenOncePerRequestFilter authenticationJwtTokenFilter() {
        return new JwtTokenOncePerRequestFilter();
    }
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 basic 明文验证
                .httpBasic().disable()
                // 前后端分离架构不需要 csrf 保护
                .csrf().disable()
                // 禁用默认登录页
                .formLogin().disable()
                // 禁用默认登出页
                .logout().disable()
                // 设置异常的 EntryPoint,如果不设置,默认使用 Http403ForbiddenEntryPoint
                .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(invalidAuthenticationEntryPoint))
                // 前后端分离是无状态的,不需要 session 了,直接禁用。
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        // 允许所有 OPTIONS 请求
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        // 允许直接访问授权登录接口
                        .requestMatchers(HttpMethod.POST, "/web/authenticate").permitAll()
                        // 允许 SpringMVC 的默认错误地址匿名访问
                        .requestMatchers("/error").permitAll()
                        // 其他所有接口必须有 Authority 信息,Authority 在登录成功后的 UserDetailsImpl 对象中默认设置“ROLE_USER”
                        //.requestMatchers("/**").hasAnyAuthority("ROLE_USER")
                        // 允许任意请求被已登录用户访问,不检查 Authority
                        .anyRequest().authenticated())
                .authenticationProvider(authenticationProvider())
                // 加我们自定义的过滤器,替代 UsernamePasswordAuthenticationFilter
                .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
 
    @Bean
    public UserDetailsService userDetailsService() {
        // 调用 JwtUserDetailService 实例执行实际校验
        return username -> userDetailsService.loadUserByUsername(username);
    }
 
    /**
     * 调用 loadUserByUsername 获得 UserDetail 信息,在 AbstractUserDetailsAuthenticationProvider 里执行用户状态检查
     *
     * @return
     */
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取 UserDetails
        authProvider.setUserDetailsService(userDetailsService());
        // 设置密码编辑器
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }
 
    /**
     * 登录时需要调用 AuthenticationManager.authenticate 执行一次校验
     *
     * @param config
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

2. JWT 请求过滤检查

/**
 * 每次请求的 Security 过滤类。执行 jwt 有效性检查,如果失败,不会设置 SecurityContextHolder 信息,会进入 AuthenticationEntryPoint
 */
public class JwtTokenOncePerRequestFilter extends OncePerRequestFilter {
 
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String token = jwtTokenProvider.resolveToken(request);
            if (token != null && jwtTokenProvider.validateToken(token)) {
                Authentication auth = jwtTokenProvider.getAuthentication(token);
                
                if (auth != null) {
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            }
        } catch (Exception e) {
            logger.error("Cannot set user authentication!", e);
        }
 
        filterChain.doFilter(request, response);
    }
 
}

3. 登录校验

考虑到 jwt 签名验签的可靠性,以及 jwt 的有效载荷并未被加密,所以 jwt 中放置了 UserDetails 接口除密码字段外其他所有字段以及项目所需的业务字段。两个目的,1. 浏览器端可以解码 jwt 的有效载荷部分的内容用于业务处理,由于不涉及到敏感信息,不担心泄密;2.服务端可以通过 jwt 的信息重新生成 UserDetails 对象,并设置到 SecurityContext 中,用于请求拦截的授权校验。

代码如下:

@RestController
@RequestMapping("/web")
public class AuthController {
 
    @PostMapping(value="/authenticate")
    public ResponseEntity<?> authenticate(@RequestBody Map<String, String> param) {
        logger.debug("登录请求参数:{}", param);
 
        try {
            String username = param.get("username");
            String password = param.get("password");
            String reqType = param.get("type");
 
            // 传递用户密码给到 SpringSecurity 执行校验,如果校验失败,会进入 BadCredentialsException
            Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
            // 验证通过,设置授权信息至 SecurityContextHolder
            SecurityContextHolder.getContext().setAuthentication(authentication);
 
            // 如果验证通过了,从返回的 authentication 里获得完整的 UserDetails 信息
            SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal();
 
            // 将用户的 ID、名称等信息保存在 jwt 的 token 中
            String token = jwtTokenProvider.createToken(userDetails.getUsername(), userDetails.getCustname(), new ArrayList<>());
 
            // 设置 cookie,本项目中非必需,因以要求必须传 head 的 Authorization: Bearer 参数
            ResponseCookie jwtCookie = jwtTokenProvider.generateJwtCookie(token);
            Map<String, Object> model = new HashMap<>();
            model.put("username", username);
            model.put("token", token);
            return ok().body(RespBody.build().ok("登录成功", model));
        } catch (BadCredentialsException e) {
            return ok(RespBody.build().fail("账号或密码错误!"));
        }
    }
}

结语

经过以上步骤,对 SpringSecurity6 结合 jwt 机制进行校验的过程就全部完成了,jwt 的工具类可以按照项目自己的情况进行编码。近期会修改我位于 github 的示例工程,提供完整的 SpringBoot3+SpringSecurity6+jwt 的示例工程。目前有一个 SpringBoot2 的老版本在这里。jwt 的工具类也可以参考老版本的这个。

「点点赞赏,手留余香」

0

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。
码云笔记 » Spring Security 6 配置方法,废弃 WebSecurityConfigurerAdapter

发表回复