被Spring Security按在地上摩擦的那几天

·吴志华
2026-07-20 13:54
阅读 482

早上八点钟,杭州的天已经大亮了。我习惯性地打开Cursor,泡了杯龙井,准备开始一天的搬砖。最近在学习AI相关的技术,顺便用Cursor帮我review了一下之前写的Spring Security代码,好家伙,不看不知道,一看全是坑。今天就来跟大家聊聊我搭Spring Security认证系统时踩的那些坑,算是给兄弟们排排雷。

坐标杭州嘛,这边阿里和网易的大厂机会确实多,身边不少朋友都在准备跳槽。上周跟一个在阿里P7的朋友吃饭,他随口问了我一句:"你们项目的认证鉴权怎么做的?"我当时就有点心虚——说实话,之前项目里的安全模块基本是照搬网上的demo,能跑就行,根本没搞明白里面的弯弯绕绕。回来之后我就下决心,必须把Spring Security这块硬骨头啃下来。

需求来了,硬着头皮上

事情是这样的,上个月我们组接了个新需求,要给现有的后台管理系统加一套完整的认证授权体系。产品经理(我又要吐槽他了,兄弟们懂的)提了一堆需求:JWT无状态认证、角色权限控制、记住我功能、OAuth2第三方登录……我当时听完就一个感觉:这需求,怕不是从哪个大厂抄来的需求文档吧?

但deadline就摆在那儿,两周后必须上线。我只能硬着头皮上。

先说项目背景吧,我们用的是Spring Boot 3.x + JDK 17,数据库是MySQL 8.0,缓存用的Redis。本来想着Spring Security嘛,Spring亲儿子,应该很好用才对。结果一上手,直接被教做人了。

第一坑:过滤器链的顺序问题

刚开始搭的时候,我照着官方文档配了个SecurityFilterChain,大概长这样:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

看着挺简单对吧?结果一跑起来,登录接口直接返回403。我当时就懵了,明明配了permitAll啊?

排查了半天,发现问题出在CORS配置上。我在另一个WebMvcConfigurer里配了跨域,但Spring Security的过滤器链优先级比它高,请求还没到Controller就被Security拦了。解决方案是把CORS配置也放到SecurityFilterChain里:

http
    .cors(Customizer.withDefaults())
    .csrf(csrf -> csrf.disable())
    // ... 其他配置

然后自定义一个CorsConfigurationSource

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://admin.xxx.com"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(List.of("*"));
    configuration.setAllowCredentials(true);
    configuration.setMaxAge(3600L);
    
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

划重点:Spring Security的过滤器链顺序非常重要,CORS、CSRF这些配置一定要放在authorizeHttpRequests之前,不然会被拦截。

第二坑:JWT过滤器的坑爹细节

JWT过滤器是认证系统的核心,我当时的实现思路是:每次请求过来,从Header里取Token,解析验证,然后塞到SecurityContext里。

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        
        String token = extractToken(request);
        
        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            String username = tokenProvider.getUsernameFromToken(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities()
                );
            authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request)
            );
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }

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

看着没毛病吧?结果线上跑了一段时间后,运维兄弟找过来了——"兄弟,你那个JWT过滤器,每个请求都去查一次数据库加载用户信息,QPS一高数据库就扛不住了啊!"

我当时心里一凉,确实,loadUserByUsername每次都会查DB,这在生产环境简直是灾难。

赶紧优化,加了Redis缓存:

@Service
public class CachedUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String USER_CACHE_PREFIX = "user:auth:";
    private static final long CACHE_EXPIRE_MINUTES = 30;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 先查缓存
        String cacheKey = USER_CACHE_PREFIX + username;
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached instanceof SecurityUser securityUser) {
            return securityUser;
        }

        // 缓存没有,查数据库
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));

        SecurityUser securityUser = new SecurityUser(user);
        
        // 写入缓存,过期时间跟JWT有效期对齐
        redisTemplate.opsForValue().set(cacheKey, securityUser, 
            CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);

        return securityUser;
    }
}

加完缓存之后,用JMeter压测了一下,QPS直接从800多飙到了3500+,效果立竿见影。

优化项 QPS 平均响应时间 P99延迟
优化前(每次查DB) ~820 120ms 450ms
优化后(Redis缓存) ~3500 28ms 85ms

第三坑:异常处理让你怀疑人生

Spring Security的异常处理也是个巨坑。默认情况下,认证失败、权限不足这些异常,它会直接重定向到默认的错误页面。但我们做的是前后端分离的API啊,你给我重定向个HTML页面是什么鬼?前端同事直接来找我了:"你接口返回的啥玩意儿?我JSON解析报错了!"

没办法,得自定义异常处理。这里有两个关键的异常处理器要配:

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, 
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(
            "{\"code\":401,\"message\":\"未登录或Token已过期\",\"data\":null}"
        );
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, 
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write(
            "{\"code\":403,\"message\":\"权限不足,拒绝访问\",\"data\":null}"
        );
    }
}

然后在SecurityConfig里注册:

http
    .exceptionHandling(exceptions -> exceptions
        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
        .accessDeniedHandler(jwtAccessDeniedHandler)
    )

这样前端就能拿到标准的JSON格式错误信息了,不会再出现解析报错的问题。

第四坑:密码编码的隐藏问题

这个坑比较隐蔽。我在做用户注册功能的时候,密码存进数据库是加密的,这没问题。但后来做"修改密码"功能时,死活验证不通过旧密码。

排查了一圈才发现,BCryptPasswordEncoder每次加密同一个密码,生成的密文都不一样(因为salt不同)。所以验证密码的时候,不能用encode()再比一次,必须用matches()方法:

@Service
public class UserService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    public void changePassword(String username, String oldPassword, String newPassword) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new RuntimeException("用户不存在"));

        // 正确做法:用matches验证
        if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
            throw new RuntimeException("旧密码不正确");
        }

        // 错误做法(我当时犯的错):
        // if (!passwordEncoder.encode(oldPassword).equals(user.getPassword())) {
        //     throw new RuntimeException("旧密码不正确");
        // }

        user.setPassword(passwordEncoder.encode(newPassword));
        userRepository.save(user);
    }
}

就这个坑,我调试了将近两个小时,当时真的想砸键盘。

关于权限模型的一点思考

说到权限控制,我们项目用的是RBAC模型,数据库设计大概是这样:

-- 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(200) NOT NULL,
    status TINYINT DEFAULT 1 COMMENT '0-禁用 1-启用',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_code VARCHAR(50) NOT NULL UNIQUE,
    role_name VARCHAR(100) NOT NULL,
    description VARCHAR(200)
);

-- 权限表
CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    perm_code VARCHAR(100) NOT NULL UNIQUE,
    perm_name VARCHAR(100) NOT NULL,
    resource_type VARCHAR(20) COMMENT 'menu/button/api',
    parent_id BIGINT DEFAULT 0
);

-- 用户角色关联
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色权限关联
CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id)
);

在SecurityUser里把权限信息一起加载出来:

public class SecurityUser implements UserDetails {

    private final User user;
    private final Set<String> permissions;

    public SecurityUser(User user) {
        this.user = user;
        this.permissions = loadPermissions(user.getId());
    }

    private Set<String> loadPermissions(Long userId) {
        // 这里通过用户->角色->权限的链路查出所有权限编码
        // 实际项目中建议也加缓存
        return permissionRepository.findPermCodesByUserId(userId);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return permissions.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }

    // ... 其他UserDetails方法省略
}

然后在Controller里就可以用@PreAuthorize注解来做细粒度的权限控制了:

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

    @PreAuthorize("hasAuthority('user:list')")
    @GetMapping
    public Result<List<UserVO>> listUsers() {
        // ...
    }

    @PreAuthorize("hasAuthority('user:delete')")
    @DeleteMapping("/{id}")
    public Result<Void> deleteUser(@PathVariable Long id) {
        // ...
    }
}

一些生产环境的运维经验

上线之后,也积累了一些运维方面的心得,分享几个比较重要的:

1. Token黑名单机制

用户注销或者修改密码后,旧的Token理论上应该失效。但JWT本身是无状态的,没法主动让它失效。我们的做法是在Redis里维护一个黑名单:

public void addToBlacklist(String token, long expireSeconds) {
    String blacklistKey = "token:blacklist:" + token;
    redisTemplate.opsForValue().set(blacklistKey, "1", expireSeconds, TimeUnit.SECONDS);
}

public boolean isBlacklisted(String token) {
    String blacklistKey = "token:blacklist:" + token;
    return Boolean.TRUE.equals(redisTemplate.hasKey(blacklistKey));
}

在JWT过滤器里加一步黑名单校验就行了。

2. 安全日志不能少

我们接入了公司的日志平台,对登录成功、登录失败、权限拒绝这些关键事件都做了日志记录。有一次就是通过日志发现有人在暴力破解某个管理员账号,赶紧加了限流和账号锁定机制。

3. 接口限流

登录接口一定要做限流,不然被刷爆了就尴尬了。我们用Redis + Lua脚本做了个简单的滑动窗口限流:

@Aspect
@Component
public class RateLimitAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) 
            RequestContextHolder.getRequestAttributes()).getRequest();
        
        String key = "rate_limit:" + rateLimit.key() + ":" + getClientIp(request);
        
        String luaScript = """
            local current = redis.call('incr', KEYS[1])
            if current == 1 then
                redis.call('expire', KEYS[1], ARGV[1])
            end
            return current
            """;
        
        Long count = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(key),
            String.valueOf(rateLimit.time())
        );

        if (count != null && count > rateLimit.maxCount()) {
            throw new RateLimitException("请求过于频繁,请稍后再试");
        }

        return point.proceed();
    }
}

写在最后

搞了这么一圈下来,对Spring Security算是有了比较深入的理解。说实话,这玩意儿的学习曲线确实陡峭,官方文档写得也有点让人头大。但一旦搞明白了它的过滤器链机制和核心概念,其实也没那么可怕。

对了,最近也在学Go,发现Go那边的安全框架生态跟Java比确实差了不少,不过Go的goroutine处理高并发请求确实爽。另外还试了试Lovable这个AI工具来搭前端页面,体验还不错,但复杂业务逻辑还是得自己写。

现在每天用Cursor写代码,效率确实提升了不少,AI帮我补全代码、review代码、解释报错,省了不少时间。不过该踩的坑还是得自己踩,AI也不是万能的嘛。

好了,今天就聊到这里。八点半了,得赶紧干活了,下午还有个需求评审要参加。兄弟们如果Spring Security方面有什么问题,欢迎评论区交流,我看到都会回的。

祝大家的代码都没有Bug,上线一次过!🎉

评论 0

最热最新
暂无评论
·吴志华Lv.1
0
影响力
0
文章
0
粉丝