1. 项目概述

如果你正在用Spring Boot开发应用,尤其是涉及用户登录、数据访问控制的Web项目,那么“安全”这个词绝对是你绕不开的核心议题。我见过太多项目,业务逻辑写得天花乱坠,结果在安全配置上草草了事,要么是默认配置一用到底,要么是东拼西凑一些代码,导致上线后漏洞百出,轻则数据泄露,重则服务瘫痪。今天,我就以一个踩过无数坑的过来人身份,和你彻底拆解Spring Boot的安全配置。这不仅仅是加个 spring-boot-starter-security 依赖那么简单,而是要从认证、授权、加密、会话管理到与前后端分离架构的深度整合,构建一套完整、健壮且易于维护的安全防线。无论你是刚接触Spring Security的新手,还是想优化现有安全体系的老手,这篇基于实战的解析都能让你避开我当年走过的弯路,真正掌握从零到一搭建企业级安全方案的硬核技能。

2. 安全基石:Spring Security核心架构与配置入口

在动手写代码之前,我们必须先理解Spring Security是怎么工作的。它本质上是一个基于过滤器链(Filter Chain)的安全框架。当一个HTTP请求到达你的Spring Boot应用时,它会经过一系列由Spring Security管理的过滤器,每个过滤器负责一项特定的安全任务,比如检查CSRF令牌、进行身份认证、执行授权决策等。

2.1 理解安全过滤器链

这个过滤器链是安全的核心。默认情况下,Spring Boot自动配置会为你创建一条包含数十个过滤器的链。你不需要记住每一个,但需要知道几个关键角色:

  • SecurityContextPersistenceFilter :负责在请求开始时从 SecurityContextRepository (如 HttpSessionSecurityContextRepository )加载安全上下文( SecurityContext ),并在请求结束时保存它。这是实现“登录状态保持”的基础。
  • UsernamePasswordAuthenticationFilter :处理表单登录,默认监听 /login 路径的POST请求,尝试从请求中提取用户名和密码进行认证。
  • FilterSecurityInterceptor :这是授权决策的最终关卡。它根据配置的访问规则( authorizeHttpRequests )和当前用户的权限,决定是放行请求还是抛出 AccessDeniedException
  • ExceptionTranslationFilter :位于 FilterSecurityInterceptor 之前,专门捕获安全相关的异常(如 AuthenticationException , AccessDeniedException ),并将其转化为相应的HTTP响应(如重定向到登录页或返回403错误)。

你的大部分配置工作,无论是自定义登录逻辑还是集成JWT,最终都是在影响这条过滤器链——增加、替换或调整其中某些过滤器的行为。

2.2 创建并理解SecurityConfig配置类

一切自定义的起点,都是创建一个继承了 WebSecurityConfigurerAdapter (Spring Security 5.7以前)或直接定义 SecurityFilterChain Bean(5.7及以后推荐方式)的配置类。现在更推荐后者,因为它更符合Spring Boot的编程风格。

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.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity // 这个注解是关键,它启用了Spring Security的Web安全支持
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 这里是配置的主战场
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/", "/home", "/css/**", "/js/**", "/images/**").permitAll() // 静态资源放行
                .requestMatchers("/admin/**").hasRole("ADMIN") // 管理员路径需要ADMIN角色
                .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") // 用户路径需要USER或ADMIN角色
                .anyRequest().authenticated() // 其他所有请求都需要认证
            )
            .formLogin(form -> form
                .loginPage("/login") // 自定义登录页面路径
                .permitAll() // 登录页面本身需要允许所有人访问
            )
            .logout(logout -> logout
                .permitAll() // 允许所有人访问注销端点
            );

        return http.build();
    }
}

关键点解析:

  • @EnableWebSecurity :这是一个组合注解,它引入了 @Configuration ,并告诉Spring Boot:“我要完全接管Web安全配置,请禁用默认的自动配置”。这是你进行深度自定义的“许可证”。
  • HttpSecurity :这个对象是配置HTTP层面安全的核心。通过它,你可以配置URL的访问规则、表单登录、注销、CSRF防护、会话管理等几乎所有行为。
  • authorizeHttpRequests :这是定义授权规则的新API(旧版是 authorizeRequests )。它使用一个 AuthorizationManagerRequestMatcherRegistry ,让你能够以声明式、流式API的方式定义哪些路径需要什么权限。规则是有顺序的, 更具体的规则应该放在更通用的规则前面

实操心得: 很多同学在配置路径匹配时容易踩坑。注意 requestMatchers 的参数。如果你在控制器用的是 @RequestMapping(“/api/”) (末尾有斜杠),那么安全配置里也必须用 /api/** 来匹配。反之亦然。另外,如果你的应用配置了 server.servlet.context-path=/myapp ,那么在安全配置的路径匹配中 不需要 包含这个上下文路径,Spring Security会自动处理。这个细节不一致导致的404或403错误,排查起来相当耗时。

3. 身份认证:从内存到数据库的完整实现

认证是安全的第一道门,解决“你是谁”的问题。Spring Security提供了高度可扩展的认证机制。

3.1 基于内存的快速认证(仅用于演示和测试)

在开发初期或编写测试用例时,基于内存的用户管理非常方便。

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = User.withDefaultPasswordEncoder() // 【注意】仅用于演示,生产环境有风险
            .username(“user”)
            .password(“password”)
            .roles(“USER”)
            .build();

    UserDetails admin = User.builder()
            .username(“admin”)
            .password(“{bcrypt}$2a$10$...(加密后的密码)”) // 推荐直接使用加密后的密码
            .roles(“USER”, “ADMIN”)
            .build();

    return new InMemoryUserDetailsManager(user, admin);
}

重要警告: User.withDefaultPasswordEncoder() 在每次启动时都会用默认编码器编码密码,但这 不是线程安全 的,且编码方式暴露在代码中, 绝对禁止在生产环境使用 。生产环境应该使用下面介绍的 PasswordEncoder Bean。

3.2 基于数据库的实战认证(生产级方案)

真实项目用户信息必然存于数据库。我们需要实现 UserDetailsService 接口,它是Spring Security加载用户数据的核心接口。

第一步:定义用户实体和Repository 假设我们有一个简单的 User 实体,包含用户名、密码和角色字段。这里使用JPA为例。

import jakarta.persistence.*;
import java.util.Collection;

@Entity
@Table(name = “sys_user”)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String roles; // 格式可以是“ROLE_USER,ROLE_ADMIN”或使用关联表

    // getters and setters
}

第二步:实现自定义的UserDetailsService 创建一个Service,从数据库加载用户,并封装成Spring Security认识的 UserDetails 对象。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
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.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(“用户未找到: “ + username));

        // 将数据库中的角色字符串(如“ROLE_USER,ROLE_ADMIN”)转换为GrantedAuthority集合
        Collection<? extends GrantedAuthority> authorities = Arrays.stream(user.getRoles().split(“,”))
                .map(String::trim)
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        // 返回Spring Security内置的User对象(它实现了UserDetails)
        return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(), // 数据库存储的应该是加密后的密码
                user.isEnabled(),   // 账户是否启用
                true,               // 账户是否未过期
                true,               // 凭证是否未过期
                !user.isLocked(),   // 账户是否未锁定
                authorities
        );
    }
}

第三步:配置PasswordEncoder(至关重要) 明文存储密码是安全大忌。必须在SecurityConfig中配置一个强密码编码器。

@Bean
public PasswordEncoder passwordEncoder() {
    // BCrypt是目前最推荐的密码哈希算法,它会自动加盐(salt),并且工作因子可调。
    return new BCryptPasswordEncoder();
    // 工作因子(强度)默认为10,数值越大越安全但越慢。范围4-31。
    // return new BCryptPasswordEncoder(12); // 使用强度12
}

在用户注册或修改密码时,必须使用这个编码器:

@Service
public class UserService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    public void registerUser(UserRegistrationDto dto) {
        User user = new User();
        user.setUsername(dto.getUsername());
        // 关键步骤:对原始密码进行编码后再存储
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        userRepository.save(user);
    }
}

CustomUserDetailsService loadUserByUsername 方法中,Spring Security会自动使用我们配置的 PasswordEncoder 来比较用户输入的密码和数据库存储的哈希值。

避坑指南: 关于密码编码器,有几点极易出错:

  1. 编码器一致性 :整个应用必须使用 同一种 PasswordEncoder 实现。如果你在注册时用了 BCryptPasswordEncoder ,但在SecurityConfig里配置了别的或者没配,认证一定会失败。
  2. 密码前缀 :Spring Security支持在存储的密码哈希前加前缀,如 {bcrypt} ,以声明其编码方式。如果你手动创建用户(如初始化脚本),密码格式应为 {bcrypt}$2a$10$... UserDetailsService 返回的 User 对象,其密码字段如果带有这种前缀,Spring Security会根据前缀选择对应的解码器进行匹配。
  3. 升级编码算法 :如果你想从弱的 NoOpPasswordEncoder (明文)升级到 BCryptPasswordEncoder ,可以使用 DelegatingPasswordEncoder 。它允许根据前缀支持多种编码器,便于迁移。配置如下: PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); 。存储的密码会自动带上类似 {bcrypt} 的前缀。

4. 授权控制:精细化的访问规则管理

认证通过后,授权决定用户“能干什么”。Spring Security的授权模型非常灵活。

4.1 基于HttpSecurity的URL级别授权

这是最常见的方式,在 SecurityFilterChain 配置中完成。

http.authorizeHttpRequests(authorize -> authorize
    // 1. 静态资源、登录页、公开API完全放行
    .requestMatchers(“/”, “/home”, “/login”, “/register”, “/api/public/**”).permitAll()
    // 2. 需要特定HTTP方法的放行(如OPTIONS预检请求,对CORS很重要)
    .requestMatchers(HttpMethod.OPTIONS, “/**”).permitAll()
    // 3. 基于角色的访问控制(Role-Based Access Control, RBAC)
    .requestMatchers(“/admin/**”).hasRole(“ADMIN”) // 必须拥有ROLE_ADMIN角色(注意会自动加前缀)
    .requestMatchers(“/user/profile”).hasAnyRole(“USER”, “ADMIN”, “EDITOR”)
    // 4. 基于权限的访问控制(更细粒度)
    .requestMatchers(“/document/delete/**”).hasAuthority(“DOCUMENT_DELETE”)
    .requestMatchers(“/document/edit/**”).hasAnyAuthority(“DOCUMENT_EDIT”, “DOCUMENT_ADMIN”)
    // 5. 基于IP或表达式的复杂控制(使用SpEL)
    .requestMatchers(“/internal/**”).access(new WebExpressionAuthorizationManager(“hasIpAddress(‘192.168.1.0/24’)”))
    // 6. 自定义访问决策(通过方法引用)
    .requestMatchers(“/project/{id}”).access(“@projectAccessControl.check(authentication, #id)”)
    // 7. 所有其他请求都需要认证
    .anyRequest().authenticated()
);

关键点解析:

  • hasRole(“ADMIN”) :会自动在传入的角色名前加上 ROLE_ 前缀,然后与用户权限进行比对。这意味着你的用户权限集合里需要包含 ROLE_ADMIN
  • hasAuthority(“DOCUMENT_DELETE”) :进行精确的权限字符串匹配,不会添加任何前缀。这适用于更细粒度的权限控制。
  • 访问顺序 :规则是从上到下按顺序匹配的。一旦匹配成功,后续规则将不再评估。因此,一定要把 最具体 的规则放在前面,把 最通用 的(如 anyRequest() )放在最后。
  • SpEL表达式 :Spring Expression Language提供了强大的动态授权能力,如 hasIpAddress isAnonymous() isRememberMe() ,甚至可以调用容器中的Bean方法。

4.2 基于注解的方法级别授权

对于Service层或Controller层的单个方法,可以使用注解进行更声明式的控制。首先需要在配置类或启动类上启用全局方法安全。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true) // 启用方法安全注解
public class SecurityConfig {
    // ... 其他配置
}

然后在你的业务方法上使用注解:

@Service
public class DocumentService {

    // @PreAuthorize 在方法执行前进行权限检查,支持SpEL
    @PreAuthorize(“hasRole(‘ADMIN’) or hasAuthority(‘DOCUMENT_VIEW_ALL’)”)
    public Document getConfidentialDocument(Long id) {
        // ...
    }

    // @PostAuthorize 在方法执行后进行权限检查,可以访问方法的返回值(returnObject)
    @PostAuthorize(“returnObject.owner == authentication.name”)
    public Document getDocument(Long id) {
        // 即使查到了文档,如果当前用户不是owner,也会抛出AccessDeniedException
    }

    // @Secured 是JSR-250标准,只支持角色名,不支持SpEL
    @Secured({“ROLE_ADMIN”, “ROLE_SUPERVISOR”})
    public void deleteDocument(Long id) {
        // ...
    }

    // @PreFilter / @PostFilter 用于过滤集合参数或返回值
    @PreFilter(“filterObject.owner == authentication.name”) // 过滤传入的list,只保留owner是当前用户的
    @PostFilter(“filterObject.status == ‘PUBLISHED’ or hasRole(‘ADMIN’)”) // 过滤返回的list
    public List<Document> processDocuments(List<Document> documents) {
        return documents;
    }
}

经验之谈: 方法级安全和URL级安全如何选择?我的建议是 结合使用 。URL级安全作为第一道粗粒度防线,快速拦截未认证或明显越权的请求。方法级安全作为第二道细粒度防线,用于保护核心业务逻辑,特别是当权限判断逻辑复杂、需要依赖业务数据(如“只能修改自己创建的文章”)时, @PreAuthorize 的SpEL表达式能力无可替代。将安全控制贴近业务逻辑,意图更清晰,也更不容易出错。

5. 前后端分离与无状态认证:JWT深度集成实战

现代应用多为前后端分离架构,传统的基于Session的认证方式(有状态)会带来扩展性和跨域问题。JSON Web Token (JWT) 是实现无状态认证的主流方案。

5.1 JWT工作流程与核心组件

  1. 登录 :前端提交用户名密码到 /api/login
  2. 验证与签发 :后端验证成功,生成一个JWT令牌(包含用户标识、过期时间等),签名后返回给前端。
  3. 携带令牌 :前端将JWT存储在本地(如localStorage),并在后续请求的 Authorization 头中携带: Bearer <token>
  4. 验证令牌 :后端通过一个自定义的过滤器拦截请求,解析并验证JWT的有效性和签名。验证通过后,将用户信息存入安全上下文。
  5. 授权访问 :后续的授权流程(URL或方法级)照常进行。

5.2 实现JWT工具类

首先,引入JWT依赖(这里使用 jjwt )。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

创建JWT工具类,负责生成和解析令牌。

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtTokenProvider {

    @Value(“${jwt.secret}”) // 从配置文件中读取密钥,严禁硬编码
    private String jwtSecret;
    @Value(“${jwt.expiration-ms}”)
    private long jwtExpirationMs;

    // 生成密钥
    private SecretKey getSigningKey() {
        // 确保密钥长度足够(HS256至少32字节)。生产环境应从安全的地方获取。
        byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 生成Token
    public String generateToken(String username, Map<String, Object> claims) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpirationMs);

        // 构建JWT
        return Jwts.builder()
                .setClaims(claims) // 自定义声明(如用户ID、角色)
                .setSubject(username) // 主题,通常放用户名
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(getSigningKey(), Jwts.SIG.HS256) // 指定算法和密钥
                .compact();
    }

    // 从Token中获取用户名(主题)
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
        return claims.getSubject();
    }

    // 验证Token
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 日志记录异常,但这里返回false
            return false;
        }
    }

    // 获取Token中的所有声明
    public Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

5.3 创建JWT认证过滤器

这是连接Spring Security和JWT的关键。我们需要创建一个过滤器,在 UsernamePasswordAuthenticationFilter 之前执行,用于解析请求头中的JWT。

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            // 1. 从请求头中提取JWT
            String jwt = parseJwt(request);
            if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
                // 2. 从JWT中解析用户名
                String username = jwtTokenProvider.getUsernameFromToken(jwt);
                // 3. 加载用户详情(从数据库或缓存)
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                // 4. 构建Authentication对象
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null, // 凭证(密码)设为null,因为JWT已证明身份
                                userDetails.getAuthorities());
                // 5. 将请求详情(如IP、SessionId)设置进去
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // 6. 将Authentication设置到安全上下文中
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error(“无法设置用户认证”, e);
            // 这里不直接抛出异常,让请求继续。后续的授权过滤器会处理未认证的情况。
        }
        // 7. 继续过滤器链
        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader(“Authorization”);
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith(“Bearer “)) {
            return headerAuth.substring(7); // 去掉”Bearer “前缀
        }
        return null;
    }
}

5.4 配置SecurityFilterChain以使用JWT

修改之前的 SecurityConfig ,关键变化是:

  1. 禁用基于Session的认证(无状态)。
  2. 禁用CSRF(因为JWT是无状态的,且通常用于API,CSRF防护意义不大,但需评估风险)。
  3. 将自定义的JWT过滤器添加到过滤器链中。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthFilter) throws Exception {
    http
        // 禁用CSRF(对于无状态API通常是安全的,但如果是浏览器应用且使用Cookie,则需要开启)
        .csrf(csrf -> csrf.disable())
        // 设置Session管理为无状态(STATELESS),不创建和使用HttpSession
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(“/api/auth/**”).permitAll() // 登录注册接口放行
            .anyRequest().authenticated()
        )
        // 在UsernamePasswordAuthenticationFilter之前添加我们的JWT过滤器
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

5.5 实现登录接口

最后,我们需要一个控制器来处理登录请求,验证凭证并返回JWT。

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

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

    @PostMapping(“/login”)
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        // 1. 使用AuthenticationManager进行认证
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(),
                        loginRequest.getPassword()
                )
        );
        // 2. 认证成功,设置安全上下文(可选,因为JWT过滤器后续会设置)
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 3. 生成JWT
        String username = authentication.getName();
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        // 可以将用户角色等信息放入claims
        Map<String, Object> claims = new HashMap<>();
        claims.put(“roles”, userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        String jwt = jwtTokenProvider.generateToken(username, claims);
        // 4. 返回Token(通常放在响应体,也可以放在HttpOnly Cookie中增强安全性)
        return ResponseEntity.ok(new JwtResponse(jwt));
    }
}

深度思考与避坑:

  1. JWT的安全存储 :前端将JWT存在 localStorage 容易受到XSS攻击。更安全的做法是存在 HttpOnly 的Cookie中(防止JS读取),但需妥善处理CSRF防护。对于纯API服务, localStorage 加HTTPS是常见选择,需权衡利弊。
  2. 令牌刷新 :JWT一旦签发,在过期前无法废止。为了实现“注销”或强制下线,可以维护一个短黑名单(如Redis,设置稍长于令牌过期时间),或者在JWT中嵌入一个版本号或随机 jti ,服务端校验时检查该标识是否有效。更常见的方案是使用 刷新令牌(Refresh Token)机制 :发放一个长期有效的刷新令牌(存于数据库或缓存)和一个短期有效的访问令牌(JWT)。访问令牌过期后,用刷新令牌换取新的访问令牌。刷新令牌可被服务端主动撤销。
  3. 性能考虑 JwtAuthenticationFilter 会对每个请求进行JWT验证和数据库查询( loadUserByUsername )。对于高并发场景,可以将用户信息缓存在Redis中,Key为用户名或用户ID,设置合理的TTL。这样过滤器只需验证JWT签名和过期时间,然后从缓存加载用户信息,极大减轻数据库压力。

6. 高级安全特性与自定义处理

除了核心的认证授权,Spring Security还提供了丰富的高级功能来处理各种边界情况。

6.1 自定义认证成功/失败处理

默认的表单登录成功后会重定向到之前请求的页面或默认成功页。在前后端分离项目中,我们通常需要返回JSON。

@Component
public class JsonAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        // 可以在这里生成JWT并返回
        Map<String, Object> body = new HashMap<>();
        body.put(“status”, “success”);
        body.put(“message”, “登录成功”);
        body.put(“username”, authentication.getName());
        new ObjectMapper().writeValue(response.getWriter(), body);
    }
}

@Component
public class JsonAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        Map<String, Object> body = new HashMap<>();
        body.put(“status”, “error”);
        body.put(“message”, “认证失败: “ + exception.getMessage());
        new ObjectMapper().writeValue(response.getWriter(), body);
    }
}

SecurityConfig 中配置它们:

http.formLogin(form -> form
    .loginProcessingUrl(“/api/auth/login”) // 处理登录请求的URL
    .successHandler(jsonAuthenticationSuccessHandler)
    .failureHandler(jsonAuthenticationFailureHandler)
    .permitAll()
);

6.2 自定义访问拒绝与认证入口点

当未认证用户访问受保护资源,或已认证用户权限不足时,需要自定义响应。

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 处理未认证(401 Unauthorized)
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        Map<String, Object> body = new HashMap<>();
        body.put(“status”, HttpStatus.UNAUTHORIZED.value());
        body.put(“error”, “Unauthorized”);
        body.put(“message”, “访问此资源需要完整的身份认证”);
        body.put(“path”, request.getServletPath());
        new ObjectMapper().writeValue(response.getWriter(), body);
    }
}

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        // 处理权限不足(403 Forbidden)
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        Map<String, Object> body = new HashMap<>();
        body.put(“status”, HttpStatus.FORBIDDEN.value());
        body.put(“error”, “Forbidden”);
        body.put(“message”, “您没有足够的权限访问该资源”);
        body.put(“path”, request.getServletPath());
        new ObjectMapper().writeValue(response.getWriter(), body);
    }
}

SecurityConfig 中配置:

http.exceptionHandling(exception -> exception
    .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 未认证处理
    .accessDeniedHandler(customAccessDeniedHandler) // 权限不足处理
);

6.3 会话管理与并发控制

对于有状态应用,可以控制Session行为。

http.sessionManagement(session -> session
    .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 默认,需要时创建
    .maximumSessions(1) // 同一用户最多允许1个活跃会话
    .maxSessionsPreventsLogin(true) // true: 达到最大会话数时阻止新登录;false: 使最旧的会话失效
    .expiredSessionStrategy(new CustomSessionExpiredStrategy()) // 会话过期处理
);

6.4 CORS(跨域资源共享)配置

前后端分离项目必须处理CORS。可以在Security配置中全局设置。

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList(“https://your-frontend-domain.com”)); // 允许的源
    configuration.setAllowedMethods(Arrays.asList(“GET”, “POST”, “PUT”, “PATCH”, “DELETE”, “OPTIONS”));
    configuration.setAllowedHeaders(Arrays.asList(“authorization”, “content-type”, “x-auth-token”));
    configuration.setExposedHeaders(Arrays.asList(“x-auth-token”)); // 暴露给前端的自定义响应头
    configuration.setAllowCredentials(true); // 允许携带凭证(如Cookie)
    configuration.setMaxAge(3600L); // 预检请求缓存时间(秒)
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration(“/**”, configuration); // 对所有路径生效
    return source;
}

// 在HttpSecurity中启用CORS配置
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

7. 生产环境安全加固与最佳实践

将Spring Security投入生产环境,仅有基础配置是远远不够的。以下是我从多次项目上线和安全审计中总结出的加固清单。

7.1 密码安全策略

  • 强制密码复杂度 :在用户注册和修改密码时,使用 Passay OWASP Java Encoder 等库强制要求密码长度、大小写字母、数字和特殊字符的组合。
  • 密码历史与过期 :实现自定义的 UserDetailsPasswordService ,在用户修改密码时检查新密码是否在最近N次使用的密码历史中。可以定期要求用户修改密码。
  • 防止密码喷洒和暴力破解 :实现登录尝试限制。可以使用 Spring Security DaoAuthenticationProvider 结合缓存(如Redis)来记录失败次数,达到阈值后锁定账户或引入验证码。
@Service
public class LoginAttemptService {
    private final Cache<String, Integer> attemptsCache = CacheBuilder.newBuilder()
            .expireAfterWrite(1, TimeUnit.HOURS).build();

    public void loginSucceeded(String key) {
        attemptsCache.invalidate(key);
    }
    public void loginFailed(String key) {
        Integer attempts = attemptsCache.getIfPresent(key);
        if (attempts == null) {
            attempts = 0;
        }
        attempts++;
        attemptsCache.put(key, attempts);
    }
    public boolean isBlocked(String key) {
        Integer attempts = attemptsCache.getIfPresent(key);
        return attempts != null && attempts >= MAX_ATTEMPT;
    }
}

7.2 敏感信息与配置管理

  • 密钥管理 :JWT签名密钥、数据库密码等 绝对禁止 硬编码在代码中。必须使用环境变量、配置服务器(如Spring Cloud Config)或专业的密钥管理服务(如HashiCorp Vault, AWS KMS)。
  • 配置文件安全 :将 application.properties application.yml 中的敏感信息移至 application-{profile}.properties ,并确保生产环境的配置文件有严格的访问控制。使用 jasypt-spring-boot-starter 等工具对配置文件中的敏感值进行加密。
  • 依赖安全扫描 :使用 OWASP Dependency-Check Snyk 等工具定期扫描项目依赖,及时修复已知漏洞。

7.3 日志与监控

  • 审计日志 :记录所有重要的安全事件,如用户登录/登出、权限变更、敏感操作(数据删除、配置修改)。Spring Security提供了 ApplicationListener<AbstractAuthenticationEvent> 接口来监听认证事件。
  • 结构化日志 :使用 SLF4J Logback Log4j2 ,输出JSON格式的日志,便于接入ELK(Elasticsearch, Logstash, Kibana)等日志分析平台。
  • 健康检查与指标 :通过Spring Boot Actuator暴露 /health /metrics 端点(务必通过安全配置保护这些端点),监控认证失败率、活跃会话数等关键指标。

7.4 HTTPS强制与安全头

  • 强制HTTPS :在生产环境,应配置服务器(如Nginx, Tomcat)或使用Spring Boot的 server.ssl.* 属性启用HTTPS,并在安全配置中重定向所有HTTP请求到HTTPS。
    http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
    
  • 安全HTTP头 :利用Spring Security的 headers() 配置或专门的库(如 spring-security-headers )添加安全头,防御常见Web攻击。
    http.headers(headers -> headers
        .contentSecurityPolicy(csp -> csp.policyDirectives(“default-src ‘self’; script-src ‘self’ …”)) // CSP
        .frameOptions(frame -> frame.sameOrigin()) // 防止点击劫持
        .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000)) // HSTS
        .xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
    );
    

7.5 定期安全复审与测试

  • 渗透测试 :定期邀请专业安全团队或使用自动化工具(如OWASP ZAP, Burp Suite)对应用进行渗透测试。
  • 代码审计 :对安全相关的代码(如认证、授权、加密、输入验证)进行定期的人工代码审查。
  • 依赖更新 :建立流程,定期更新Spring Boot、Spring Security及其它第三方依赖到最新稳定版,以获取安全补丁。

安全配置不是一劳永逸的,它是一个持续的过程。从理解核心架构开始,扎实地实现认证授权,平滑地集成到你的前后端架构中,最后用生产级的加固措施将它包裹起来。这套组合拳下来,你的Spring Boot应用才能在各种潜在威胁面前站稳脚跟。记住,安全没有银弹,唯有多层次、纵深防御,才能构建真正可靠的服务。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐