Spring Security 实战:从表单登录到 JWT 接口鉴权

在 Java 后端开发中,Spring Security 几乎是事实标准的安全框架。它既能用几行配置就给应用加上表单登录与密码保护,也能平滑演进为基于 JWT 的接口鉴权方案,支撑前后端分离的无状态架构。本文从默认安全行为讲起,一步步落地「数据库认证 + 方法级授权 + JWT 令牌」的完整链路,所有代码均可在 Spring Boot 3 直接运行。

一、为什么需要 Spring Security

一个没有防护的 Web 应用,任何人都可能直接调用管理接口、篡改数据。Spring Security 在 Servlet 过滤器链上统一拦截请求,提供认证(你是谁)、授权(你能做什么)和防护(CSRF、CORS、会话固定)三道防线。它与 Spring Boot 自动配置深度集成,开箱即可用。

  • 认证:校验用户名密码、令牌、OAuth2 等凭证
  • 授权:基于角色或表达式控制接口访问
  • 防护:CSRF、CORS、点击劫持、会话管理

二、快速接入:默认表单登录

2.1 引入依赖与最小配置

只需一个 starter,Spring Boot 就会自动装配一条默认安全过滤器链,并为应用生成一个随机密码(控制台可见)。

<!-- pom.xml -->
<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>

2.2 自定义 SecurityFilterChain

实际项目中我们都会显式声明过滤器链,明确哪些路径放行、哪些需要认证,以及登录页与登出行为。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/actuator/health").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login").permitAll())
            .logout(logout -> logout.permitAll());
        return http.build();
    }
}

这段配置等价于「除公开路径外全部需要登录」,并启用了 Spring Security 自带的表单登录与登出。关于 Spring Boot 自动配置如何装配这些 Bean,可参考本站《Spring Boot 自动配置原理》。

三、基于数据库的认证

3.1 实现 UserDetailsService

默认的内存用户无法满足生产需求,我们需要从数据库加载用户,并实现 UserDetailsService。

@Service
public class JpaUserDetailsService implements UserDetailsService {

    private final UserRepository repo;

    public JpaUserDetailsService(UserRepository repo) {
        this.repo = repo;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        AppUser u = repo.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
        return org.springframework.security.core.userdetails.User
            .withUsername(u.getUsername())
            .password(u.getPassword())          // 已加密存储
            .authorities(u.getRoles().toArray(new String[0]))
            .build();
    }
}

3.2 密码加密:永远用 BCrypt

明文存储密码是严重安全事故。Spring Security 提供了 PasswordEncoder,BCrypt 自带盐值且自适应慢哈希,是当前推荐方案。

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

// 注册用户时加密
String hashed = passwordEncoder.encode(rawPassword);
user.setPassword(hashed);

四、方法级授权与角色控制

除了 URL 层面,Spring Security 还能在方法上做精细授权。开启 @EnableMethodSecurity 后,可用注解约束服务层。

@PreAuthorize("hasRole('ADMIN')")
public void deleteTenant(Long id) {
    tenantRepo.deleteById(id);
}

@PreAuthorize("hasAuthority('ORDER:READ')")
public Order getOrder(Long id) {
    return orderRepo.findById(id).orElseThrow();
}

注解里的 hasRole 会自动补上前缀 ROLE_,而 hasAuthority 匹配完整权限串,二者不要混用。

五、无状态 JWT 接口鉴权

前后端分离架构下,服务端通常不保持会话。做法是:用户登录后由后端签发 JWT,前端后续请求在 Authorization: Bearer 头携带令牌,后端用过滤器校验,全程无状态。

5.1 JWT 工具类

@Component
public class JwtUtil {
    private final String secret = "change-me-base64-32bytes-min";
    private final long ttl = 3600_000L; // 1h

    public String issue(String username, List<String> roles) {
        return Jwts.builder()
            .subject(username)
            .claim("roles", roles)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + ttl))
            .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
            .compact();
    }

    public Claims parse(String token) {
        return Jwts.parser()
            .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
            .build().parseSignedClaims(token).getPayload();
    }
}

5.2 登录接口签发 Token

@PostMapping("/api/login")
public Map<String,String> login(@RequestBody LoginReq req) {
    Authentication auth = authManager.authenticate(
        new UsernamePasswordAuthenticationToken(req.username(), req.password()));
    UserDetails u = (UserDetails) auth.getPrincipal();
    String token = jwtUtil.issue(u.getUsername(),
        u.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList());
    return Map.of("token", token);
}

5.3 过滤器校验 Token

自定义 OncePerRequestFilter 拦截请求,解析 JWT 并设置 SecurityContext,再把登录接口放行。

public class JwtFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String h = req.getHeader("Authorization");
        if (h != null && h.startsWith("Bearer ")) {
            Claims c = jwtUtil.parse(h.substring(7));
            var auth = new UsernamePasswordAuthenticationToken(
                c.getSubject(), null, parseRoles(c));
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(req, res);
    }
}

六、CORS 与静态资源放行

分离部署时前端域与后端域不同,需要显式配置 CORS,并放行登录、刷新等公开接口,避免被过滤器链拦截。

http
  .cors(withDefaults())
  .csrf(csrf -> csrf.ignoringRequestMatchers("/api/login", "/api/refresh"))
  .authorizeHttpRequests(a -> a
      .requestMatchers("/api/login", "/api/public/**").permitAll()
      .requestMatchers("/api/**").authenticated());

七、单元测试与安全校验

安全逻辑必须用测试兜底。借助 @WithMockUser 可以模拟登录用户,验证授权是否生效。

@Test
@WithMockUser(roles = "USER")
void userCannotDeleteTenant() {
    assertThrows(AccessDeniedException.class,
        () -> service.deleteTenant(1L));
}

八、常见误区与排障

现象根因修复
登录后仍为匿名UserDetails 密码未加密/编码器不匹配统一使用同一 PasswordEncoder
hasRole 不生效角色未加 ROLE_ 前缀用 hasAuthority 或补齐前缀
JWT 一直 401时钟漂移 / 密钥不一致校验服务器时间与服务间密钥
OPTIONS 预检被拦CORS 未放行预检放行 OPTIONS 或开启 withDefaults()

九、落地清单

维度表单登录方案JWT 无状态方案
会话服务端保持 HttpSession无会话,令牌自包含
扩展依赖会话复制/粘滞天然水平扩展
适用传统服务端渲染前后端分离 / 移动端
登出销毁 session令牌过期或加黑名单

从默认表单登录到 JWT 接口鉴权,Spring Security 的演进路径非常平滑:先用好 SecurityFilterChain 与 UserDetailsService 搭好认证地基,再切换到无状态令牌。配合 Spring Boot 自动配置与 JUnit5 安全测试,即可让接口既安全又可维护。

上一篇 技术复盘实战:用无指责复盘把事故变成团队资产
下一篇 Vite 实战:极速前端开发与构建流水线搭建
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]