Spring Security 基础:快速搭建安全认证系统(别再裸奔了!)
作者:一个被爬虫和认证需求轮番轰炸的成都后端工程师,日常靠 Claude 写 CRUD,偶尔研究分布式事务到凌晨三点
上周五晚上十一点半,我正躺在沙发上刷《黑神话》,突然钉钉“叮”一声——产品小李发来消息:“哥,咱们后台管理界面能不能加个登录?现在谁都能进,刚有运营同学手滑删了全站用户配置……”
我默默放下 Switch,叹了口气。这已经是本月第三次因为没做权限控制出事了。我们团队有个“优良传统”:MVP(Minimum Viable Product)做到极致,能跑就行,安全?那是下个迭代的事。
但这次真不行了。领导已经拍桌子:“再出一次事故,整个组周末团建改上线值班!” 我寻思着,与其每次修修补补,不如直接上 Spring Security,一劳永逸。
于是,这个周末我没去太古里喝咖啡,也没去青城山吸氧,而是窝在出租屋,一边啃兔头一边搭认证系统。今天这篇,就是我的实战复盘,全程案例驱动,不讲理论八股,只聊怎么快速把安全防护搞起来,顺便防住那些想偷偷爬我们接口的家伙。
背景:为什么是现在?
其实我一直对 Spring Security 有点抗拒。倒不是它不好,而是配置太“魔法”了——各种 @EnableWebSecurity、UserDetailsService、PasswordEncoder,看文档像看天书。之前项目都用公司封装好的 OAuth2 中间件,开箱即用,哪用操心这些?
但这次是个新项目,独立部署,没中间件可用。而且,最近发现有人在用脚本疯狂请求我们的公开 API,虽然没敏感数据,但 QPS 直接飙到 500+,数据库连接池差点被打爆。运维老王在群里咆哮:“再这样下去,服务器成本要超预算了!”
所以,这次的目标很明确:
- 快速实现用户名/密码登录
- 区分普通用户和管理员权限
- 对接口做基础频率限制(防爬虫)
- 所有敏感操作留审计日志
说白了,就是一个轻量但完整的安全认证骨架。别整那些花里胡哨的 SSO、JWT 双令牌刷新,先让系统别裸奔!
开搞:从零搭建认证模块
第一步:依赖安排上
新建一个 Spring Boot 3.x 项目(注意,3.x 默认用 Jakarta EE 9,包名变了),先把 Security 依赖加上:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
顺手建个用户表,结构极简:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| username | VARCHAR(50) | 唯一 |
| password | VARCHAR(100) | 加密存储 |
| role | ENUM('USER','ADMIN') | 角色 |
| enabled | TINYINT(1) | 是否启用 |
💡 小贴士:生产环境千万别用
VARCHAR(50)存密码明文!后面会用 BCrypt 加密。
第二步:核心配置 —— 别怕,其实就三块
Spring Security 的核心是 过滤器链(Filter Chain)。你只要告诉它:
- 哪些路径要保护?
- 用户信息从哪来?
- 密码怎么校验?
于是我建了个 SecurityConfig.java:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll() // 公开接口
.requestMatchers("/admin/**").hasRole("ADMIN") // 管理员专属
.anyRequest().authenticated() // 其他都要登录
)
.formLogin(form -> form
.loginPage("/login") // 自定义登录页
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.csrf().disable(); // 开发阶段先关掉,上线记得开!
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这段代码其实很直白:
/public/**谁都能访问(比如健康检查)/admin/**必须是 ADMIN 角色- 登录走表单,成功后跳转 dashboard
- 密码用 BCrypt 加密(强度默认 10,够用)
但坑就藏在细节里。
踩坑实录:那些让我想砸键盘的瞬间
坑 1:角色前缀 ROLE_ 是个隐形炸弹
我数据库里存的是 role = 'ADMIN',但死活进不了 /admin 页面,返回 403。查了半天日志,发现 Spring Security 默认会给角色加前缀 ROLE_,也就是说,它实际校验的是 ROLE_ADMIN。
解决办法有两个:
- 要么数据库存
ROLE_ADMIN - 要么在配置里关掉前缀:
@Bean
public GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults(""); // 禁用 ROLE_ 前缀
}
我选了后者,更干净。
坑 2:自定义 UserDetailsService 返回的权限集合必须带 ROLE_
即使你禁用了前缀,在 UserDetails 实现里,getAuthorities() 返回的权限字符串仍然要手动加 ROLE_,否则权限校验还是失败。
我的 UserDetailsServiceImpl 长这样:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
// 注意!这里必须加 ROLE_ 前缀
Collection<? extends GrantedAuthority> authorities =
Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole())
);
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.isEnabled(),
true, true, true,
authorities
);
}
}
🤦♂️ 当时真的想砸电脑:文档里根本没强调这点!全靠 Stack Overflow 救命。
坑 3:CSRF 不是随便关的
开发时为了方便我把 CSRF 关了,结果上线前一天测试说“登录按钮点不动”。一查 Network,POST 请求被拦截了——因为前端没带 _csrf token。
解决方案:
- 如果用 Thymeleaf 模板,加
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> - 如果是前后端分离,建议用 JWT 或 Session + Cookie,CSRF 防护另做处理
但我们项目是服务端渲染,所以最后还是开了 CSRF,并在登录表单里加了隐藏字段。
综合防御:不止是登录,还要防爬虫
光有认证不够。上周那个爬虫事件让我意识到:公开接口也得有限流。
Spring Security 本身不提供限流,但我们可以结合 OncePerRequestFilter 自己写一个简易版。
思路很简单:
- 用 Redis 记录每个 IP 在时间窗口内的请求次数
- 超过阈值就返回 429
@Component
public class RateLimitFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
private static final int MAX_REQUESTS = 100; // 每分钟最多100次
private static final int WINDOW_SECONDS = 60;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
String clientIp = getClientIP(request);
String key = "rate_limit:" + clientIp;
Integer count = redisTemplate.opsForValue().get(key);
if (count == null) {
redisTemplate.opsForValue().set(key, 1, Duration.ofSeconds(WINDOW_SECONDS));
} else if (count >= MAX_REQUESTS) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Too many requests");
return;
} else {
redisTemplate.opsForValue().increment(key);
}
filterChain.doFilter(request, response);
}
private String getClientIP(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
然后在 SecurityConfig 里注册:
http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);
✅ 效果立竿见影:第二天监控显示异常 IP 的请求量断崖式下跌。运维老王终于笑了。
生产经验:上线前必做的三件事
密码加密强度别偷懒
BCrypt 的strength参数默认是 10,足够安全。别为了“性能”改成 4,那等于裸奔。Session 超时设置合理
在application.yml里配:server: servlet: session: timeout: 1800s # 30分钟无操作自动登出审计日志不能少
用@EventListener监听登录登出事件:@EventListener public void onAuthenticationSuccess(AuthenticationSuccessEvent event) { log.info("用户 {} 登录成功", event.getAuthentication().getName()); }
总结:安全不是功能,是底线
折腾完这套系统,总共花了不到两天。现在:
- 后台管理界面必须登录才能进
- 管理员操作隔离清晰
- 公开接口有基础限流,不怕简单爬虫
- 所有登录行为可追溯
最重要的是,再也不用半夜被报警电话叫醒了。
Spring Security 其实没那么可怕。它的“复杂”更多是因为灵活性太高,而我们往往只需要 20% 的功能就能解决 80% 的问题。关键是要理解它的核心模型:认证(你是谁) + 授权(你能干啥) + 过滤器链(怎么拦你)。
至于那些分布式 Session、OAuth2、OIDC……等业务真的需要时再上也不迟。别一上来就想着造航空母舰,先把自行车骑稳了再说。
对了,产品小李昨天又来找我:“哥,能不能加个短信验证码登录?”
我微微一笑:“行啊,不过得排期,下个月再说。”
毕竟,成都的火锅还在等我呢。
附:常用配置速查表
| 场景 | 配置方式 |
|---|---|
| 放行静态资源 | .requestMatchers("/css/**", "/js/**").permitAll() |
| 自定义登录页 | .formLogin().loginPage("/login") |
| 角色权限校验 | .hasRole("ADMIN") 或 .hasAuthority("ROLE_ADMIN") |
| 禁用 CSRF | .csrf().disable()(仅开发!) |
| 密码加密 | new BCryptPasswordEncoder() |
| Session 超时 | server.servlet.session.timeout=1800s |
代码已上传 GitHub(私信我拿链接),欢迎 star & 提 issue。如果你也在成都搞 Java,约个茶馆聊聊分布式锁?

评论 0