1. 前言

又是新的一周,博主继续l来给大家更新 Spring Security 实战教程系列啦~ 通过前面的章节教程从 认证到授权
,相信大家已经基本了解 Spring Security 的工作原理。

但在 ** 前后端分离架构 ** 成为主流的今天,传统的 Session-Cookie 认证模式面临跨域限制、服务端状态维护等难题。
JWT(JSON Web Token) 作为无状态令牌方案,凭借其自包含、易扩展的特性,成为现代分布式系统的首选认证方案。

那么本章节,博主就带着大家一起来进行 SpringSecurity 前后端分离认证实战,手把手教构建安全的 JWT 认证体系!


2.JWT 基本原理

JWT(JSON Web Token) 是一种开放标准(RFC 7519),用于在各方之间以 JSON
对象安全地传输信息。其主要特点包括:

  • • ** 无状态 ** :服务端无需保存会话信息,降低了服务端压力(传统Session是保存服务端)
  • • ** 跨域支持 ** :适用于前后端分离应用场景

JWT 通常由三部分组成: Header、Payload 和 Signature 。在认证场景中,用户登录后
服务器生成一个包含用户信息的Token ,前端将该 Token 存储在本地,并在后续请求中携带到 HTTP Header
中。服务端通过解析和验证 Token,完成用户身份认证。


3.Spring Security 与 JWT 整合思路

整合 JWT Spring Security 的关键在于:

  • • ** 无状态配置 ** :关闭 Spring Security 默认的 Session 管理,采用无状态认证
  • • ** 自定义认证入口 ** :提供一个登录接口,验证用户凭据,生成 JWT
  • • ** JWT 拦截过滤器 ** :在请求到达业务逻辑前,拦截 HTTP 请求,解析和验证 JWT,将用户认证信息写入 SecurityContext

** 完整流程图如下: **
在这里插入图片描述


4. 实战开始

本次演示我们先简单模拟集成,还是在之前的 ** Maven ** 项目中新建子模块 命名: ** jwt-spring-security **
Maven配置文件追加JWT库

<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt</artifactId>  
    <version>0.12.6</version>  
</dependency>

YML配置文件中添加密钥以及Token 有效期

jwt:  
  secret: "dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIG15IGFwcA==" # Base64编码密钥  
  expiration: 900000 # 15分钟

4.1 配置JWT工具类

如果你的系统使用了 Hutool 工具包,可以直接调用 JWTUtil 来创建以及验证JWT,具体参考https://doc.hutool.cn/pages/JWTUtil/

这里我们使用的是 io.jsonwebtoken 来自定义JWT

@Component  
publicclassJwtUtils {  
  
    @Value("${jwt.secret}")  
    private String secret;  
  
    @Value("${jwt.expiration}")  
    privatelong expiration;  
  
    // 生成令牌时设置subject  
    public String generateToken(String username) {  
        return Jwts.builder()  
                .subject(username) // 关键:设置用户名到subject  
                .issuedAt(newDate())  
                .expiration(newDate(System.currentTimeMillis() + expiration))  
                .signWith(getSigningKey())  
                .compact();  
    }  
  
    // 用户名提取方法  
    public String extractUsername(String token) {  
        return parseClaims(token).getSubject();  
    }  
  
    // 统一的令牌验证方法  
    publicbooleanvalidateToken(String token) {  
        parseClaims(token); // 复用解析逻辑  
        returntrue;  
    }  
  
    // 校验Token是否过期  
    publicbooleanisTokenExpired(String token) {  
        Claimsclaims= parseClaims(token);  
        return claims.getExpiration().before(newDate());  
    }  
  
    // 私有方法:统一解析令牌声明  
    private Claims parseClaims(String token) {  
        return Jwts.parser()  
                .verifyWith(getSigningKey())  
                .build()  
                .parseSignedClaims(token)  
                .getPayload();  
    }  
  
    // 密钥生成方法  
    private SecretKey getSigningKey() {  
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);  
        return Keys.hmacShaKeyFor(keyBytes);  
    }  
}

4.2 JwtAuthFilter:JWT 过滤器

在前面章节我们讲解 Spring Security 底层原理的时候,我们知道 Spring Security 默认 DefaultSecurityFilterChain 启动的时候,会通过多个 Filter 来逐层检查,实际上同理只需要我们自定义JWT
过滤器来实现我们所需业务即可

@Component  
@RequiredArgsConstructor  
publicclassJwtAuthFilterextendsOncePerRequestFilter {  
  
    privatefinal JwtUtils jwtUtils;  
  
    @Override  
    protectedvoiddoFilterInternal(HttpServletRequest request,  
                                    HttpServletResponse response,  
                                    FilterChain filterChain)throws ServletException, IOException {  
        // 从请求头获取 Token,约定使用 "Authorization" 且前缀为 "Bearer "  
        Stringtoken= parseToken(request);  
        if (token != null && jwtUtils.validateToken(token)) {  
  
            Stringusername= jwtUtils.extractUsername(token);  
            // 如果 token 存在且 SecurityContext 为空,设置用户认证  
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {  
                // 这里只是示例,实际应用中应加载用户详情信息  
                UsernamePasswordAuthenticationTokenauthToken=  
                        newUsernamePasswordAuthenticationToken(username, null, Collections.emptyList());  
                authToken.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));  
  
                // 将认证信息放入上下文中  
                SecurityContextHolder.getContext().setAuthentication(authToken);  
            }  
        }  
        filterChain.doFilter(request, response);  
    }  
  
    private String parseToken(HttpServletRequest request) {  
        Stringheader= request.getHeader("Authorization");  
        if (header != null && header.startsWith("Bearer ")) {  
            return header.substring(7);  
        }  
        returnnull;  
    }  
}

4.3 安全配置类

Spring Security 配置类主要关闭 session 管理,并追加自定义 JWT Filter
放行 /api/auth/login 接口地址,其余均需要验证JWT

@Configuration  
//开启方法级的安全控制  
@EnableMethodSecurity  
@RequiredArgsConstructor  
publicclassJwtSecurityConfig {  
  
    privatefinal JwtAuthFilter jwtAuthFilter;  
  
    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception {  
        http  
                // 禁用 CSRF,因为使用 JWT 方式无需 Session  
                .csrf(csrf -> csrf.disable())  
                // 设置无状态 Session 管理  
                .sessionManagement(session -> session  
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
                .authorizeHttpRequests(auth -> auth  
                        .requestMatchers("/api/auth/login").permitAll()  
                        .anyRequest().authenticated())  
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)  
                ;  
        return http.build();  
    }  
}

4.4 配置测试controller

编写一个登陆接口 ** /api/auth/login ** 以及一个验证接口 ** /api/auth/verify **

@RestController  
@RequestMapping("/api/auth")  
@RequiredArgsConstructor  
publicclassAuthController {  
  
    privatefinal JwtUtils jwtUtils;  
  
    // 简单示例,真实场景中应从数据库加载用户信息并校验密码  
    @PostMapping("/login")  
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {  
        // 这里假设用户名为 "admin",密码为 "admin"  
        if ("admin".equals(loginRequest.getUsername()) && "admin".equals(loginRequest.getPassword())) {  
            Stringtoken= jwtUtils.generateToken(loginRequest.getUsername());  
            return ResponseEntity.ok(token);  
        }  
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误");  
    }  
  
    // 简单示例,真实场景中应从数据库加载用户信息并校验密码  
    @GetMapping("/verify")  
    public ResponseEntity<?> verify() {  
        return ResponseEntity.ok("验证用户访问成功");  
    }  
}

4.5 运行测试

这里博主使用的 Apifox 测试效果,首先登陆获得token
在这里插入图片描述
在接下来的验证接口,配置Header 设置Authorization的Key,并将登陆获得的token设置值

格式:Bearer +登陆获得的Token

在这里插入图片描述
至此我们就完成了最简单的 JWT+SpringSecurity 整合


5. 基于RBAC角色模型的升级

回忆一下我们之前第五章节中,RBAC角色模型的表设计: 需要通过用户ID查询到用户分配的角色+角色所配置的菜单资源
在这里插入图片描述
这里我们就基于 最新Spring Security实战教程(五)基于数据库的动态用户认证传统RBAC角色模型实战开发
复用代码,集合JWT动态从数据库获取用户信息认证、授权

5.1 复用第五章节RBAC角色模型代码

相关RBAC角色模型的知识,请小伙伴自行查阅第五章节内容,篇幅有限这里就简单贴出代码即可

** 实体类 **

// SysMenu实体类  
@Data  
@TableName("sys_menu")  
publicclassSysMenu {  
    @TableId(type = IdType.AUTO)  
    private Long menuId;  
    private String menuName;  
    private String perms;  
}  
  
// SysRole实体类  
@Data  
@TableName("sys_role")  
publicclassSysRole {  
    @TableId(type = IdType.AUTO)  
    private Long roleId;  
    private String roleName;  
    private String roleKey;  
  
    @TableField(exist = false)  
    private List<SysMenu> menus;  
}  
  
// SysUser实体类  
// 博主为了方便,直接使用数据库映射的SysUser对象直接实现UserDetails,大家在开发过程中建议单独构建实现对象!  
@Data  
@TableName("sys_user")  
publicclassSysUserimplementsUserDetails {  
  
    @TableId(type = IdType.AUTO)  
    private Long userId;  
  
    @TableField("login_name")  
    private String username; // Spring Security认证使用的字段  
  
    private String password;  
  
    private String status; // 状态(0正常 1锁定)  
  
    private String delFlag; // 删除标志(0代表存在 1代表删除)  
  
    @TableField(exist = false)  
    private List<SysRole> roles;  
  
    // 实现UserDetails接口  
    @Override  
    public Collection<? extendsGrantedAuthority> getAuthorities() {  
        // 组装 GrantedAuthority 集合,将角色和菜单权限都加入  
        Set<GrantedAuthority> authorities = newHashSet<>();  
        authorities.addAll(roles.stream()  
                .map(role -> newSimpleGrantedAuthority("ROLE_" + role.getRoleKey()))  
                .collect(Collectors.toList()));  
  
        authorities.addAll(roles.stream()  
                .flatMap(role -> role.getMenus().stream())  
                .map(menu -> newSimpleGrantedAuthority(menu.getPerms()))  
                .collect(Collectors.toList()));  
        return authorities;  
    }  
  
    @Override  
    publicbooleanisAccountNonExpired() { returntrue; }  
  
    @Override  
    publicbooleanisAccountNonLocked() {  
        return"0".equals(status);  
    }  
  
    @Override  
    publicbooleanisCredentialsNonExpired() { returntrue; }  
  
    @Override  
    publicbooleanisEnabled() {  
        return"0".equals(delFlag);  
    }  
}

** Mapper配置 **

// MenuMapper配置  
@Mapper  
publicinterfaceMenuMapperextendsBaseMapper<SysMenu> {  
  
@Select("SELECT DISTINCT m.* FROM sys_menu m " +  
         "JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " +  
         "JOIN sys_user_role ur ON rm.role_id = ur.role_id " +  
         "WHERE ur.user_id = #{userId}")  
 List<SysMenu> selectByUserId(Long userId);  
}  
  
// RoleMapper配置  
@Mapper  
publicinterfaceRoleMapperextendsBaseMapper<SysRole> {  
  
    @Select("SELECT m.* FROM sys_menu m " +  
            "JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " +  
            "WHERE rm.role_id = #{roleId}")  
    List<SysMenu> selectMenusByRoleId(Long roleId);  
}  
  
// UserMapper配置  
@Mapper  
publicinterfaceUserMapperextendsBaseMapper<SysUser> {  
  
    @Select("SELECT r.* FROM sys_role r " +  
            "JOIN sys_user_role ur ON r.role_id = ur.role_id " +  
            "WHERE ur.user_id = #{userId}")  
    @Results({  
            @Result(property = "roleId", column = "role_id"),  
            @Result(property = "menus", column = "role_id",  
                    many = @Many(select = "com.toher.springsecurity.demo.jwt.mapper.MenuMapper.selectByUserId"))  
    })  
    List<SysRole> selectRolesByUserId(Long userId);  
}

5.2 自定义UserDetailsService实现

自定义 UserDetailsService 继承 UserDetailsService ,重写 loadUserByUsername 方法,注入 UserMapper 以及 roleMapper
通过用户名查询数据库数据,同时将用户的角色、菜单资源集合一并赋值;

@Service  
@RequiredArgsConstructor  
publicclassUserDetailsServiceImplimplementsUserDetailsService {  
  
    privatefinal UserMapper userMapper;  
    privatefinal RoleMapper roleMapper;  
  
    @Override  
    public UserDetails loadUserByUsername(String username)throws UsernameNotFoundException {  
  
        // 1. 查询基础用户信息  
        LambdaQueryWrapper<SysUser> wrapper = newLambdaQueryWrapper<>();  
        wrapper.eq(SysUser::getUsername, username);  
        SysUseruser= userMapper.selectOne(wrapper);  
  
        if (user == null) {  
            thrownewUsernameNotFoundException("用户不存在");  
        }  
  
        // 2. 加载角色和权限  
        List<SysRole> roles = userMapper.selectRolesByUserId(user.getUserId());  
        roles.forEach(role ->  
                role.setMenus(roleMapper.selectMenusByRoleId(role.getRoleId()))  
        );  
        user.setRoles(roles);  
  
        // 3. 检查账户状态  
        if (!user.isEnabled()) {  
            thrownewDisabledException("用户已被禁用");  
        }  
        return user;  
    }  
}

5.3 改造JwtAuthFilter

这里我们需要调用 自定义UserDetailsService loadUserByUsername 方法从数据库获取用户信息,
其中 user.getAuthorities() 包含了用户角色、资源相关权限信息( ** 具体查阅SysUser对象 ** )

@Component  
@RequiredArgsConstructor  
publicclassJwtAuthFilterextendsOncePerRequestFilter {  
  
    privatefinal JwtUtils jwtUtils;  
    privatefinal UserDetailsServiceImpl userDetailsService;  
  
    @Override  
    protectedvoiddoFilterInternal(HttpServletRequest request,  
                                    HttpServletResponse response,  
                                    FilterChain filterChain)throws ServletException, IOException {  
        // 从请求头获取 Token,约定使用 "Authorization" 且前缀为 "Bearer "  
        Stringtoken= parseToken(request);  
        if (token != null && jwtUtils.validateToken(token)) {  
            Stringusername= jwtUtils.extractUsername(token);  
            UserDetailsuser= userDetailsService.loadUserByUsername(username);  
            //创建认证信息  
            UsernamePasswordAuthenticationTokenauthToken=  
                    newUsernamePasswordAuthenticationToken(user, token, user.getAuthorities());  
            authToken.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));  
            // 将认证信息放入上下文中  
            SecurityContextcontext= SecurityContextHolder.createEmptyContext();  
            context.setAuthentication(authToken);  
            SecurityContextHolder.setContext(context);  
  
        }  
        filterChain.doFilter(request, response);  
    }  
  
    private String parseToken(HttpServletRequest request) {  
        Stringheader= request.getHeader("Authorization");  
        if (header != null && header.startsWith("Bearer ")) {  
            return header.substring(7);  
        }  
        returnnull;  
    }  
}

5.4 创建authService处理用户登陆

我们专门创建一个 authService 用以处理用户的登陆,用户查询存在则返回JWT token

@Service  
@RequiredArgsConstructor  
publicclassAuthService {  
  
    privatefinal AuthenticationManager authenticationManager;  
    privatefinal JwtUtils jwtUtils;  
  
    public AuthResponse authenticate(LoginRequest request) {  
        // authenticationManager会调用 userDetailsService.loadUserByUsername 方法  
        Authenticationauthentication= authenticationManager  
                .authenticate(newUsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));  
        SysUseruser= (SysUser)authentication.getPrincipal();  
  
        Stringtoken= jwtUtils.generateToken(user.getUsername());  
        returnnewAuthResponse(token, user.getUsername(), user.getAuthorities());  
    }  
}

** 另外还有两个DTO **
LoginRequest :用来接收登陆用户名、密码
AuthResponse :用于返回token、用户的权限信息

@Data  
publicclassLoginRequest {  
    private String username;  
    private String password;  
}  
  
  
@Data  
@AllArgsConstructor  
publicclassAuthResponse {  
    private String token;  
    private String username;  
    private Collection<?> authorities;  
}

5.5 调整JwtSecurityConfig配置

@Configuration  
//开启方法级的安全控制  
@EnableMethodSecurity  
@RequiredArgsConstructor  
publicclassJwtSecurityConfig {  
  
    privatefinal JwtAuthFilter jwtAuthFilter;  
  
    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception {  
        http  
                // 禁用 CSRF,因为使用 JWT 方式无需 Session  
                .csrf(csrf -> csrf.disable())  
                // 设置无状态 Session 管理  
                .sessionManagement(session -> session  
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
                .authorizeHttpRequests(auth -> auth  
                        .requestMatchers("/api/auth/login").permitAll()  
                        .requestMatchers("/api/auth/loginByMysql").permitAll()  
                        .anyRequest().authenticated())  
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)  
                .exceptionHandling(ex -> ex  
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint())  
                );  
        return http.build();  
    }  
  
    //统一认证凭证处理  
    @Bean  
    public AuthenticationEntryPoint jwtAuthenticationEntryPoint() {  
        return (request, response, authException) -> {  
            response.setContentType("application/json;charset=UTF-8");  
            response.getWriter().write("{\"code\": 401,\"msg\": \"无效的认证凭证\"}");  
        };  
    }  
  
    // 自定义认证中使用 AuthenticationManager  
    @Bean  
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)throws Exception {  
        return configuration.getAuthenticationManager();  
    }  
      
    //定义密码加密方式  
    @Bean  
    public PasswordEncoder passwordEncoder() {  
        returnnewBCryptPasswordEncoder();  
    }  
}

5.6 定义接口统一返回Result + 全局异常处理

之前简单的整合过程中,小伙伴或许发现了接口返回的非JSON数据,且token过期等没有正确的错误提示,这里我们继续完善一下

@Data  
@NoArgsConstructor  
@AllArgsConstructor  
@Accessors(chain = true)  
publicclassAjaxResult<T> {  
  
    privatestaticfinallongserialVersionUID=1L;  
  
    /**  
     * 状态码  
     */  
    privateint code;  
  
    /**  
     * 返回内容  
     */  
    private String msg;  
  
    /**  
     * 数据对象  
     */  
    private T data;  
  
    /**  
     * 初始化一个新创建的 AjaxResult 对象  
     *  
     * @param code 状态码  
     * @param msg  返回内容  
     */  
    publicAjaxResult(int code, String msg) {  
        this.code = code;  
        this.msg = msg;  
    }  
  
    /**  
     * 返回成功消息  
     *  
     * @return 成功消息  
     */  
    publicstatic AjaxResult<Void> success() {  
        return AjaxResult.success("操作成功");  
    }  
  
    /**  
     * 返回成功数据  
     *  
     * @return 成功消息  
     */  
    publicstatic <T> AjaxResult<T> success(T data) {  
        return AjaxResult.success("操作成功", data);  
    }  
  
    /**  
     * 返回成功消息  
     *  
     * @param msg 返回内容  
     * @return 成功消息  
     */  
    publicstatic AjaxResult<Void> success(String msg) {  
        return AjaxResult.success(msg, null);  
    }  
  
    /**  
     * 返回成功消息  
     *  
     * @param msg  返回内容  
     * @param data 数据对象  
     * @return 成功消息  
     */  
    publicstatic <T> AjaxResult<T> success(String msg, T data) {  
        returnnewAjaxResult<>(200, msg, data);  
    }  
  
    /**  
     * 返回错误消息  
     *  
     * @return  
     */  
    publicstatic AjaxResult<Void> error() {  
        return AjaxResult.error("操作失败");  
    }  
  
    /**  
     * 返回错误消息  
     *  
     * @param msg 返回内容  
     * @return 警告消息  
     */  
    publicstatic AjaxResult<Void> error(String msg) {  
        return AjaxResult.error(msg, null);  
    }  
  
    /**  
     * 返回错误消息  
     *  
     * @param msg  返回内容  
     * @param data 数据对象  
     * @return 警告消息  
     */  
    publicstatic <T> AjaxResult<T> error(String msg, T data) {  
        returnnewAjaxResult<>(500, msg, data);  
    }  
  
    /**  
     * 返回错误消息  
     *  
     * @param code 状态码  
     * @param msg  返回内容  
     * @return 警告消息  
     */  
    publicstatic AjaxResult<Void> error(int code, String msg) {  
        returnnewAjaxResult<>(code, msg, null);  
    }  
  
}

** 定义全局异常处理类 **

@RestControllerAdvice  
public class GlobalExceptionHandler {  
     /**  
     * 全局异常  
     */  
    @ExceptionHandler(Exception.class)  
    public AjaxResult handleException(Exception e) {  
        return AjaxResult.error(e.getMessage());  
    }  
}

5.7 controller中登陆、测试方法

    //通过数据库用户数据登陆  
    @PostMapping("/loginByMysql")  
    public AjaxResult<AuthResponse> loginByMysql(@RequestBody LoginRequest request) {  
        return AjaxResult.success(authService.authenticate(request));  
    }  
  
    //验证权限  
    @PreAuthorize("hasAuthority('admin:menu:add')")  
    @GetMapping("/add")  
    public AjaxResult<Void> add() {  
        return AjaxResult.success("方法的授权admin:menu:add,访问ok");  
    }

5.8 运行测试

访问 /loginByMysql 接口查看返回的用户数据,前端可以根据登陆接口返回决定资源权限的配置

在这里插入图片描述

最后切换用户测试 /add 仅配置了 ** admin:menu:add ** 允许访问

在这里插入图片描述
最后修改token、或者当token过期,继续请求查看是否被 AuthenticationEntryPoint 统一处理
在这里插入图片描述


6. 结语

至此本章节内容也就结束了,我们通过 Spring Security JWT 实现无状态认证。 从 JWT
的基本原理出发,逐步构建了登录接口、JWT
生成工具、拦截过滤器及安全配置的初步整合方案,最后再结合之前章节RBAC角色模型升级,完整实现了动态数据库用户的认证授权等
。通过完整的代码样例,相信小伙伴们都可以快速搭建出一个高效、灵活的认证系统。