当前位置: 首页 > news >正文

【安全篇】金刚不坏之身:整合 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,添加以下依赖:

<!-- 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 配置:

    # 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

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;@PostConstructpublic 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:
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 是公开的,哪些是受保护的@Beanpublic 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. 配置密码编码器@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
}

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


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

1. 改造 User 实体和创建认证服务
  • 修改 User.java: 添加 passwordrole 字段。
// 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)

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

AuthController.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 responsepublic record JwtAuthenticationResponse(String accessToken) {}// 你还需要在 UserRepository 中添加 findByEmail 方法
}

UserRepository.java

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

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

config 包下创建 JwtAuthenticationFilter.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 的核心服务@Overrideprotected 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

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;@Overridepublic 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,把我们的过滤器加进去。

// SecurityConfig.java
// ... imports
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration
@EnableWebSecurity
public class SecurityConfig {@Autowired private JwtAuthenticationFilter jwtAuthenticationFilter;@Beanpublic 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 的方法上添加注解:
// 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 与配置中心初探》,我们将学习如何优雅地管理应用的配置,让它能轻松地在开发、测试、生产等不同环境中切换。我们下期再会!

相关文章:

  • 数据结构第5章:树和二叉树完全指南(自整理详细图文笔记)
  • 【leetcode】136. 只出现一次的数字
  • 实现自动化管理、智能控制、运行服务的智慧能源开源了。
  • Oauth认证过程中可能会出现什么问题和漏洞?
  • ubuntu22.04有线网络无法连接,图标也没了
  • OPenCV CUDA模块光流处理------利用Nvidia GPU的硬件加速能力来计算光流类cv::cuda::NvidiaHWOpticalFlow
  • 第22节 Node.js JXcore 打包
  • 技能伤害继承英雄属性【War3地图编辑器】进阶
  • TCP/IP 网络编程 | 服务端 客户端的封装
  • OPENCV形态学基础之二腐蚀
  • Vue 3 实战:【加强版】公司通知推送(WebSocket + token 校验 + 心跳机制)
  • docker nginx解决跨域请求的处理(https的也支持)
  • 今日学习:Spring线程池|并发修改异常|链路丢失|登录续期|VIP过期策略|数值类缓存
  • 商品中心—1.B端建品和C端缓存的技术文档二
  • 商品中心—1.B端建品和C端缓存的技术文档一
  • ThinkPHP8中使用QueryList---QueryList 简洁、优雅、可扩展的PHP采集工具(爬虫)
  • Spring Bean的初始化过程是怎么样的?​​
  • Vue 实例的数据对象详解
  • 阿里云Ubuntu 22.04 64位搭建Flask流程(亲测)
  • Django、Flask、FastAPI与Jupyter对比
  • 域名注册好了如何做网站/免费seo课程
  • 大连做网站哪家好/网络安全培训
  • 企业网站的切片怎么做/百度小说排行榜完本
  • 网站开发运营维护方案建议文档/百度地图在线使用
  • 厦门做网站的公司/中山排名推广
  • 临潼区做网站的公司/提高工作效率英语