Spring Boot 3 集成 Spring Security + JWT

Spring Boot 3 集成 Spring Security + JWT

准备工作

概述: 在本文中,我们将一步步学习如何使用 Spring Boot 3 和 Spring Security 来保护我们的应用程序。我们将从简单的入门开始,然后逐渐引入数据库,并最终使用 JWT 实现前后端分离。

引入依赖

这里主要用到了Mybatis-plus、hutool 、knife4j ,其他依赖可以直接勾选

xml 复制代码
 <properties>
        <java.version>17</java.version>

        <mybatisplus.version>3.5.9</mybatisplus.version>
        <knife4j.version>4.5.0</knife4j.version>
        <hutool.version>5.8.26</hutool.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </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>

        <!-- MyBatis-Plus https://baomidou.com-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-jsqlparser</artifactId>
        </dependency>

        <!--Knife4j https://doc.xiaominfo.com/-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
            <version>${knife4j.version}</version>
        </dependency>

        <!-- Java工具类库 https://doc.hutool.cn -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-bom</artifactId>
                <version>${mybatisplus.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

我这里使用的Spring boot版本为3.3.5 ,使用3.4.0整合JWT过滤器时,打开swagger会报错:jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoSuchMethodError: 'void org.springframework.web.method.ControllerAdviceBean.<init>(java.lang.Object) ,说是版本兼容问题。暂时没有找到很好的解决方案,所以给Spring boot版本降至3.3.5

设计表结构

关于表结构内容我这里不详细的说了,各个表字段内容,可以拉一下代码,获取表结构sql脚本。关注公众号:"Harry技术",回复"jwt",即可获取到整个项目源码以及表结构。

sql 复制代码
sys_config 系统配置表
sys_dept 部门表
sys_dict 字典表
sys_dict_data 字典数据表
sys_menu 菜单表
sys_role 角色表
sys_role_menu 角色菜单关系表
sys_user 用户表
sys_user_role 用户角色关系表

生成基本代码

白名单配置

因为我们这里引入knife4j ,关于knife4j 的相关配置可以参考《Spring Boot 3 整合Knife4j(OpenAPI3规范)》,我们需要将以下接口加入到白名单

yml 复制代码
  # 白名单列表
  ignore-urls:
    - /v3/api-docs/**
    - /doc.html
    - /swagger-resources/**
    - /webjars/**
    - /swagger-ui/**
    - /swagger-ui.html

JWT配置

JWT(JSON Web Token)相关资料网络上非常多,可以自行搜索,简单点说JWT就是一种网络身份认证和信息交换格式。

  • Header 头部信息,主要声明了JWT的签名算法等信息
  • Payload 载荷信息,主要承载了各种声明并传递明文数据
  • Signature 签名,拥有该部分的JWT被称为JWS,也就是签了名的JWT,用于校验数据

整体结构是:

text 复制代码
header.payload.signature

配置参数jwt密码、过期时间等

  • yml 配置
yaml 复制代码
# 安全配置
security:
  jwt:
    # JWT 秘钥
    key: www.tech-harry.cn
    # JWT 有效期(单位:秒)
    ttl: 7200
  # 白名单列表
  ignore-urls:
    - /v3/api-docs/**
    - /doc.html
    - /swagger-resources/**
    - /webjars/**
    - /swagger-ui/**
    - /swagger-ui.html
    - /auth/login
  • 创建SecurityProperties
java 复制代码
/**
 * Security Properties
 *
 * @author harry
 * @公众号 Harry技术
 */
@Data
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {

    /**
     * 白名单 URL 集合
     */
    private List<String> ignoreUrls;

    /**
     * JWT 配置
     */
    private JwtProperty jwt;


    /**
     * JWT 配置
     */
    @Data
    public static class JwtProperty {

        /**
         * JWT 密钥
         */
        private String key;

        /**
         * JWT 过期时间
         */
        private Long ttl;

    }
}

自定义未授权和未登录结果返回

在之前的案例中没有自定义未授权和未登录,直接在页面上显示错误信息,这样对于前端来说不是很好处理,我们将所有接口按照一定的格式返回,会方便前端交互处理。

  • 未登录

    java 复制代码
    /**
     * 当未登录或者token失效访问接口时,自定义的返回结果
     *
     * @author harry
     * @公众号 Harry技术
     */
    @Component
    public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json");
            response.getWriter().println(JSONUtil.toJsonStr(R.unauthorized(authException.getMessage())));
    
            response.getWriter().flush();
        }
    
    }
  • 未授权

    java 复制代码
    /**
     * 当访问接口没有权限时,自定义的返回结果
     *
     * @author harry
     * @公众号 Harry技术
     */
    @Component
    public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json");
            response.getWriter().println(JSONUtil.toJsonStr(R.forbidden(e.getMessage())));
            response.getWriter().flush();
        }
    
    }

创建JWT过滤器

这里直接使用了Hutool-jwt提供的JWTUtil工具类,主要包括:JWT创建、JWT解析、JWT验证。

java 复制代码
/**
 * JWT登录授权过滤器
 *
 * @author harry
 * @公众号 Harry技术
 */
@Slf4j
public class JwtValidationFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;

    // 密钥
    private final byte[] secretKey;

    public JwtValidationFilter(UserDetailsService userDetailsService, String secretKey) {
        this.userDetailsService = userDetailsService;
        this.secretKey = secretKey.getBytes();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, @Nonnull HttpServletResponse response, @Nonnull FilterChain chain) throws ServletException, IOException {
        // 获取请求token
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        try {
            // 如果请求头中没有Authorization信息,或者Authorization以Bearer开头,则认为是匿名用户
            if (StrUtil.isBlank(token) || !token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
                chain.doFilter(request, response);
                return;
            }

            // 去除 Bearer 前缀
            token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
            // 解析 Token
            JWT jwt = JWTUtil.parseToken(token);

            // 检查 Token 是否有效(验签 + 是否过期)
            boolean isValidate = jwt.setKey(secretKey).validate(0);
            if (!isValidate) {
                log.error("JwtValidationFilter error: token is invalid");
                throw new ApiException(ResultCode.UNAUTHORIZED);
            }
            JSONObject payloads = jwt.getPayloads();
            String username = payloads.getStr(JWTPayload.SUBJECT);
            SysUserDetails userDetails = (SysUserDetails) this.userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (Exception e) {
            log.error("JwtValidationFilter error: {}", e.getMessage());
            SecurityContextHolder.clearContext();
            throw new ApiException(ResultCode.UNAUTHORIZED);
        }
        // Token有效或无Token时继续执行过滤链
        chain.doFilter(request, response);
    }
}

改写SecurityConfig

关于Spring Boot 3 集成 Spring Security相关的知识点,可以参考文章:《Spring Boot 3 集成 Spring Security(1)认证》、《Spring Boot 3 集成 Spring Security(2)授权》、《Spring Boot 3 集成 Spring Security(3)数据管理》。

java 复制代码
/**
 * Spring Security 权限配置
 *
 * @author harry
 * @公众号 Harry技术
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // 开启方法级别的权限控制
@RequiredArgsConstructor
public class SecurityConfig {

    private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
    private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    private final SecurityProperties securityProperties;
    private final UserDetailsService userDetailsService;

    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        // 忽略的路径
        http.authorizeHttpRequests(requestMatcherRegistry -> requestMatcherRegistry.requestMatchers(
                        securityProperties.getIgnoreUrls().toArray(new String[0])).permitAll()
                .anyRequest().authenticated()
        );

        http
                // 由于使用的是JWT,我们这里不需要csrf
                .csrf(AbstractHttpConfigurer::disable)
                // 禁用session
                .sessionManagement(configurer ->
                        configurer
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // 添加自定义未授权和未登录结果返回
        http.exceptionHandling(customizer ->
                customizer
                        // 处理未授权
                        .accessDeniedHandler(restfulAccessDeniedHandler)
                        // 处理未登录
                        .authenticationEntryPoint(restAuthenticationEntryPoint));
        // JWT 校验过滤器
        http.addFilterBefore(new JwtValidationFilter(userDetailsService, securityProperties.getJwt().getKey()), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    /**
     * AuthenticationManager 手动注入
     *
     * @param authenticationConfiguration 认证配置
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

这里主要做了以下几点配置:

  • 将不需要认证鉴权的接口加入白名单

  • 由于使用的是JWT,我们这里不需要csrf、禁用session

  • 添加自定义未授权和未登录结果返回

  • 配置 JWT 校验过滤器

我们根据数据库中的用户信息加载用户,并将角色转换为 Spring Security 能识别的格式。我们写一个SysUserDetails类来实现自定义Spring Security 用户对象。

java 复制代码
/**
 * 用户详情服务
 *
 * @author harry
 * @公众号 Harry技术
 */
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final SysUserMapper sysUserMapper;
    private final SysMenuMapper sysMenuMapper;
    private final SysUserRoleMapper sysUserRoleMapper;

    @Override
    @Cacheable(value = CacheConstants.USER_DETAILS, key = "#username", unless = "#result == null ")
    public SysUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        // 获取登录用户信息
        SysUser user = sysUserMapper.selectByUsername(username);

        // 用户不存在
        if (BeanUtil.isEmpty(user)) {
            throw new ApiException(SysExceptionEnum.USER_NOT_EXIST);
        }
        Long userId = user.getUserId();

        // 用户停用
        if (StatusEnums.DISABLE.getKey().equals(user.getStatus())) {
            throw new ApiException(SysExceptionEnum.USER_DISABLED);
        }

        // 获取角色
        Set<String> roles = sysUserRoleMapper.listRoleKeyByUserId(userId);

        // 获取数据范围标识
        Integer dataScope = sysUserRoleMapper.getMaximumDataScope(roles);

        Set<String> permissions = new HashSet<>();
        // 如果 roles 包含 root 则拥有所有权限
        if (roles.contains(CommonConstant.SUPER_ADMIN_ROOT)) {
            permissions.add(CommonConstant.ALL_PERMISSION);
        } else {
            // 获取菜单权限标识
            permissions = sysMenuMapper.getMenuPermission(userId);
            // 过滤空字符串
            permissions.remove("");
        }

        return new SysUserDetails(user, permissions, roles, username, dataScope);
    }

}

这里使用了@Cacheable结合redis做的缓存处理,关于缓存相关配置,可以参考文章《Spring Boot 3 整合Redis(1) 基础功能》、《Spring Boot 3 整合Redis(2)注解驱动缓存》。

登录验证

  • 写一个登录接口/auth/login,返回 token、tokenType等信息
java 复制代码
/**
 * 登录相关
 *
 * @author harry
 * @公众号 Harry技术
 */
@Slf4j
@RestController
@RequiredArgsConstructor
@Tag(name = "认证中心")
@RequestMapping("/auth")
public class LoginController {

    private final SysUserService sysUserService;

    @Operation(summary = "login 登录")
    @PostMapping(value = "/login")
    public R<LoginResult> login(@RequestBody SysUserLoginParam sysUserLoginParam) {

        return R.success(sysUserService.login(sysUserLoginParam.getUsername(), sysUserLoginParam.getPassword()));
    }

    @Operation(summary = "info 获取当前用户信息")
    @GetMapping(value = "/info")
    public R<UserInfoResult> getInfo() {
        UserInfoResult result = sysUserService.getInfo();
        return R.success(result);
    }

    @Operation(summary = "logout 注销")
    @PostMapping(value = "/logout")
    public R logout(HttpServletRequest request) {
        // 需要 将当前用户token 设置无效
        SecurityContextHolder.clearContext();
        return R.success();
    }

}
  • LoginResult 对象

    java 复制代码
    /**
     *
     * @author harry
     * @公众号 Harry技术
     */
    @Data
    public class LoginResult {
    
        @Schema(description = "token")
        private String token;
    
        @Schema(description = "token 类型", example = "Bearer")
        private String tokenType;
    
        @Schema(description = "过期时间(单位:秒)", example = "604800")
        private Long expiration;
    
        @Schema(description = "刷新token")
        private String refreshToken;
    
    }

启动查看接口

访问http://localhost:8080/swagger-ui/index.html或者http://localhost:8080/doc.html

未登录

当我们处于未登录状态时访问/auth/info接口,直接返回了我们自定义的异常信息

登录

这里我们登录用户 harry/123456,设定用户角色TEST,菜单权限不给字典相关的操作。

看到接口成功返回token等信息,我们将token信息填写到 Authorize,作为全局配置。

这时,我们访问/auth/info,可以看到当前登录的用户信息

我们访问字典相关的接口,如:/sys_dict/page,返回了没有相关权限的信息

访问其他接口,如:/sys_dept/page,可以看到数据正常返回。

总结

到这里,我们已经掌握了Spring Boot 3 整合 Security 的全过程。我们将从简单的入门开始,然后学习如何整合数据库,并最终使用 JWT 实现前后端分离。这些知识将帮助我们构建更安全、更可靠的应用程序。后续我们会深入了解在项目中用到的一些其他框架、工具。让我们一起开始吧!

示例源码:关注公众号"Harry技术",回复 jwt 获取源码地址。

文章推荐

相关推荐
漂流瓶66666623 分钟前
Scala的模式匹配变量类型
开发语言·后端·scala
夏天吃哈密瓜28 分钟前
Scala中的正则表达式01
大数据·开发语言·后端·正则表达式·scala
2401_8337880530 分钟前
Scala的模式匹配(2)
java·开发语言
悠悠龙龙2 小时前
框架模块说明 #05 权限管理_03
java·开发语言·spring
开心羊咩咩3 小时前
Idea 2024.3 突然出现点击run 运行没有反应,且没有任何提示。
java·ide·intellij-idea
waterme1onY3 小时前
IDEA中MAVEN的一些设置问题
java·maven·intellij-idea
阿华的代码王国3 小时前
【算法】——前缀和(矩阵区域和详解,文末附)
java·开发语言·算法·前缀和
梦.清..3 小时前
面向对象(二)——类和对象(上)
java
Mercury_@224 小时前
JAVA设计模式,责任链模式
java·设计模式
不修×蝙蝠4 小时前
数据结构--二叉树的创建和遍历
java·数据结构·二叉树·深度遍历·广度遍历·迭代法·递归法