被Spring Security按在地上摩擦的那几天
早上八点钟,杭州的天已经大亮了。我习惯性地打开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