【安全篇】金刚不坏之身:整合 Spring Security + JWT 实现无状态认证与授权

摘要

本文是《Spring Boot 实战派》系列的第四篇。我们将直面所有 Web 应用都无法回避的核心问题:安全。文章将详细阐述认证(Authentication)授权(Authorization的核心概念,对比传统 Session-Cookie 与现代 JWT(JSON Web Token)的优劣。

我们将从零开始,一步步整合强大的 Spring Security 框架,并结合 JWT 实现一套无状态(Stateless)、适用于前后端分离架构的认证授权体系。读者将学会如何创建登录接口、生成和解析 Token、保护需要权限的 API,并最终实现基于注解的精细化方法级权限控制。完成本章,你将能为任何 Spring Boot 应用构建起坚不可摧的安全防线。

系列回顾:

在前三篇文章中,我们已经构建了一个功能完备且接口优雅的 CRUD 应用。它有规范的 API、健壮的异常处理和严格的参数校验。但它现在是"夜不闭户"的,任何人都可以随意调用接口增删改查。这在真实世界中是致命的。是时候给我们的应用穿上"金刚不坏之身"了!

欢迎来到充满挑战与机遇的第四站!

安全,是 Web 开发的"生命线"。一个没有安全机制的应用,就像一座没有门锁的宝库,里面的数据和功能可以被任意窃取和滥用。今天,我们将要学习的,就是如何为我们的应用铸造一把牢不可破的"锁"。

我们将要面对两个核心概念:

  1. 认证 (Authentication): 你是谁?------ 验证用户身份的过程,通常是通过用户名和密码。
  2. 授权 (Authorization): 你能干什么?------ 验证用户是否有权限执行某个操作,比如"只有管理员才能删除用户"。

我们将使用业界标准的 Spring Security 框架来处理这一切。虽然它以"配置复杂"著称,但别担心,我会带你绕过所有坑,直达核心。并且,我们将采用现代前后端分离架构中最流行的 JWT (JSON Web Token) 方案,实现无状态认证。


第一步:理论先行 ------ 为什么选择 JWT?

在前后端分离的架构下,服务端不再存储用户的会话信息(Session),每一次请求都必须是独立的、自包含的。这就是无状态 (Stateless)

  • 传统 Session-Cookie 方案 (有状态):

    1. 用户登录,服务端验证成功后,创建一个 Session 对象存储用户信息,并生成一个 Session ID。
    2. 服务端将 Session ID 通过 Cookie 返回给浏览器。
    3. 浏览器后续每次请求都会带上这个 Cookie。
    4. 服务端根据 Session ID 找到对应的 Session,从而知道是哪个用户。
    • 缺点: 服务端需要存储大量 Session,在分布式环境下,需要解决 Session 共享问题(如使用 Redis 共享 Session),扩展性较差。
  • JWT 方案 (无状态):

    1. 用户登录,服务端验证成功后,将用户的核心信息(如用户ID、角色)编码成一个加密的字符串(Token)。
    2. 服务端将这个 Token 直接返回给客户端(前端)。
    3. 客户端(前端)将 Token 存储起来(比如在 localStoragesessionStorage 中)。
    4. 后续每次请求,客户端都通过请求头(Authorization Header)将 Token 发送给服务端。
    5. 服务端收到 Token 后,用密钥进行解密验证,无需查询数据库或缓存就能确认用户身份和权限。
    • 优点: 服务端无需存储任何会话信息,天然适合分布式和微服务架构,扩展性极好。

一个 JWT Token 通常长这样:xxxxx.yyyyy.zzzzz,由三部分组成:

  • Header (头部): 包含了 Token 的类型和所使用的加密算法。
  • Payload (载荷): 包含了你想传递的数据,如用户 ID、用户名、过期时间等(切记不要放敏感信息如密码!)。
  • Signature (签名): 将前两部分加上一个密钥(secret)进行加密生成。服务端用这个签名来验证 Token 是否被篡改。

理论讲完,开始实战!


第二步:添加依赖,引入 Security 和 JWT

打开 pom.xml,添加以下依赖:

xml 复制代码
<!-- Spring Boot Security 启动器 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JJWT (Java JWT) 库,用于生成和解析 JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

注意: 仅仅添加 spring-boot-starter-security 依赖后,直接重启应用,你会发现你所有的 API 都无法访问了,会弹出一个登录框。这是 Spring Security 的默认行为,它会保护所有路径。我们的任务就是自定义这个行为。


第三步:创建 JWT 工具类

我们需要一个工具类来专门负责生成和解析 JWT。

  1. com.example.myfirstapp 下创建 config 包。

  2. application.properties 中添加 JWT 配置:

    properties 复制代码
    # JWT Settings
    jwt.secret=your-super-secret-key-that-is-long-enough-for-hs256
    jwt.expiration-ms=86400000 # 24 hours

    强烈建议: jwt.secret 应该是一个足够长且复杂的随机字符串,并且不应硬编码在代码里,最好通过环境变量注入。

  3. config 包下创建 JwtTokenProvider.java

java 复制代码
package com.example.myfirstapp.config;

import com.example.myfirstapp.entity.User;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private static final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration-ms}")
    private long jwtExpirationInMs;

    private Key key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
    }

    public String generateToken(User user) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);

        return Jwts.builder()
                .setSubject(Long.toString(user.getId())) // 将用户ID作为 subject
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public Long getUserIdFromJWT(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        return Long.parseLong(claims.getSubject());
    }

    public boolean validateToken(String authToken) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.error("JWT validation error: {}", e.getMessage());
        }
        return false;
    }
}

第四步:配置 Spring Security

这是最核心的一步。我们将创建一个配置类,告诉 Spring Security:

  • 哪些 URL 是公开的(如登录、注册),不需要认证。
  • 哪些 URL 是受保护的,需要认证。
  • 如何处理登录请求。
  • 如何使用我们自定义的 JWT 过滤器来验证 Token。
  1. config 包下创建 SecurityConfig.java:
java 复制代码
package com.example.myfirstapp.config;

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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 1. 定义哪些 URL 是公开的,哪些是受保护的
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF 防护,因为我们使用 JWT,是无状态的
            .csrf(csrf -> csrf.disable())
            // 配置会话管理为无状态,不使用 Session
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 配置 URL 的授权规则
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/auth/**").permitAll() // 登录/注册接口公开
                .requestMatchers("/users/**").hasRole("ADMIN") // 用户管理接口需要 ADMIN 角色
                .anyRequest().authenticated() // 其他所有请求都需要认证
            );

        // TODO: 在这里添加 JWT 过滤器

        return http.build();
    }

    // 2. 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

注意: 上面的代码还未完成,我们还需要实现 JWT 过滤器并添加到 securityFilterChain 中。BCryptPasswordEncoder 是 Spring Security 推荐的密码加密方式,它会自动加盐,非常安全。


第五步:实现登录逻辑和 JWT 过滤器

1. 改造 User 实体和创建认证服务
  • 修改 User.java: 添加 passwordrole 字段。
java 复制代码
// User.java
public class User {
    // ... id, name, email ...
    
    private String password;
    private String role; // e.g., "ROLE_USER", "ROLE_ADMIN"
    
    // ... getters and setters for new fields ...
}
  • 创建 AuthService.javaAuthController.java:

com.example.myfirstapp下创建 servicedto 包。

LoginRequest.java (DTO)

java 复制代码
package com.example.myfirstapp.dto;
// DTO for login request
public record LoginRequest(String email, String password) {}

AuthController.java

java 复制代码
package com.example.myfirstapp.controller;

import com.example.myfirstapp.config.JwtTokenProvider;
import com.example.myfirstapp.dto.LoginRequest;
import com.example.myfirstapp.entity.User;
import com.example.myfirstapp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired private UserRepository userRepository;
    @Autowired private PasswordEncoder passwordEncoder;
    @Autowired private JwtTokenProvider tokenProvider;

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        Optional<User> userOptional = userRepository.findByEmail(loginRequest.email());

        if (userOptional.isPresent() && passwordEncoder.matches(loginRequest.password(), userOptional.get().getPassword())) {
            String jwt = tokenProvider.generateToken(userOptional.get());
            return ResponseEntity.ok(new JwtAuthenticationResponse(jwt));
        } else {
            return ResponseEntity.status(401).body("Invalid credentials");
        }
    }
    // DTO for JWT response
    public record JwtAuthenticationResponse(String accessToken) {}

    // 你还需要在 UserRepository 中添加 findByEmail 方法
}

UserRepository.java

java 复制代码
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
2. 创建 JWT 认证过滤器

这个过滤器是核心,它会在每个受保护的请求到达时,从 Authorization 头中提取 Token,验证它,并设置 Spring Security 的上下文。

config 包下创建 JwtAuthenticationFilter.java

java 复制代码
package com.example.myfirstapp.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired private JwtTokenProvider tokenProvider;
    @Autowired private UserDetailsService userDetailsService; // Spring Security 的核心服务

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Long userId = tokenProvider.getUserIdFromJWT(jwt);

                // 从数据库加载用户信息
                UserDetails userDetails = userDetailsService.loadUserByUsername(userId.toString());
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // 设置到 Spring Security 上下文中
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
3. 实现 UserDetailsService

Spring Security 通过 UserDetailsService 来加载用户信息。我们需要提供一个自己的实现。

service 包下创建 CustomUserDetailsService.java

java 复制代码
package com.example.myfirstapp.service;

import com.example.myfirstapp.entity.User;
import com.example.myfirstapp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = userRepository.findById(Long.valueOf(userId))
                .orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + userId));

        return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(),
                Collections.singleton(new SimpleGrantedAuthority(user.getRole())));
    }
}
4. 完善 SecurityConfig

最后,回到 SecurityConfig.java,把我们的过滤器加进去。

java 复制代码
// SecurityConfig.java
// ... imports
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/auth/**").permitAll()
                // .requestMatchers("/users/**").hasRole("ADMIN") // 暂时注释,先测试认证
                .anyRequest().authenticated()
            )
            // 在 UsernamePasswordAuthenticationFilter 之前添加我们的 JWT 过滤器
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    // ... passwordEncoder bean
}

第六步:测试与方法级授权

  1. 准备数据: 手动在数据库中插入一个用户,密码要用 BCrypt 加密后的。你可以写个小程序生成,或者在注册功能中实现。
  2. 测试登录: 使用 Postman 调用 POST /api/auth/login,传入正确的邮箱和密码,你会得到一个 JWT Token。
  3. 测试受保护接口: 调用 GET /users/all,不带 Token,会返回 403 Forbidden。带上 Token (在 Headers 中添加 Authorization: Bearer <your_jwt_token>),就能成功访问。
方法级授权 (@PreAuthorize)

现在,我们来实现更精细的权限控制。

  1. 开启方法级安全:SecurityConfig 上添加 @EnableMethodSecurity
  2. 修改 SecurityConfig :取消对 /users/** 的全局 hasRole 配置,因为我们要在方法上控制。
  3. UserController 的方法上添加注解:
java 复制代码
// UserController.java
import org.springframework.security.access.prepost.PreAuthorize;

@RestController
@RequestMapping("/users")
public class UserController {
    // ...

    @GetMapping("/all")
    @PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色的用户才能调用
    public Result<List<User>> getAllUsers() {
        // ...
    }

    @DeleteMapping("/delete/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<Void> deleteUserById(@PathVariable Long id) {
        // ...
    }
}

现在,即使用户登录了,如果他的角色不是 ROLE_ADMIN,调用这两个接口也一样会收到 403 Forbidden。


总结与展望

这一章内容非常密集,但恭喜你坚持了下来!你已经掌握了 Spring Boot 安全体系中最核心、最实用的部分:

  • 理解了 JWT 无状态认证的原理和优势。
  • 整合了 Spring Security,并自定义了安全策略。
  • 实现了登录接口,能够生成和验证 JWT Token。
  • 构建了 JWT 认证过滤器,保护了应用的 API。
  • 学会了使用 @PreAuthorize 实现方法级的精细化授权

你的应用现在不仅功能强大,而且固若金汤。它已经非常接近一个企业级的应用了。

在接下来的文章中,我们将从后端转向应用的"可维护性"和"性能优化"。下一篇 《【配置篇】告别硬编码:多环境配置、@ConfigurationProperties 与配置中心初探》,我们将学习如何优雅地管理应用的配置,让它能轻松地在开发、测试、生产等不同环境中切换。我们下期再会!

相关推荐
SimonKing4 分钟前
吊打面试官系列:BeanFactory和FactoryBean的区别
java·后端·面试
FlyingBird~21 分钟前
CocosCreator 之 JavaScript/TypeScript和Java的相互交互
java·javascript·typescript
神仙别闹1 小时前
基于Java+VUE+MariaDB实现(Web)仿小米商城
java·前端·vue.js
风象南1 小时前
SpringBoot的4种抽奖活动实现策略
java·spring boot·后端
蓝桉~MLGT1 小时前
java高级——高阶函数、如何定义一个函数式接口类似stream流的filter
java·开发语言·python
CYRUS_STUDIO1 小时前
一文搞懂 SO 脱壳全流程:识别加壳、Frida Dump、原理深入解析
android·安全·逆向
Bruce_Liuxiaowei1 小时前
深度剖析OpenSSL心脏滴血漏洞与Struts2远程命令执行漏洞
struts·安全·web安全
甜甜的资料库2 小时前
基于微信小程序的作业管理系统源码数据库文档
java·数据库·微信小程序·小程序
~Yogi3 小时前
今日学习:Spring线程池|并发修改异常|链路丢失|登录续期|VIP过期策略|数值类缓存
学习·spring·缓存
科技小E5 小时前
打手机检测算法AI智能分析网关V4守护公共/工业/医疗等多场景安全应用
人工智能·安全·智能手机