Spring Security基础:快速搭建安全认证系统
上周五晚上9点半,我正一边听着 Radiohead 的《Creep》循环播放,一边盯着屏幕上那个该死的 403 Forbidden 错误发呆。这是我在新公司入职后的第二个月,刚被分配到一个内部管理后台的重构项目——前端用 Vue3,后端是 Spring Boot,看起来人畜无害。结果,产品经理轻飘飘一句:“咱们这个系统得加个登录和权限控制哈”,直接把我送进了安全框架的深水区。
更离谱的是,第二天就是 Sprint 评审会,而我的本地连个 /login 都打不开。那一刻我真的想把 MacBook 直接从窗户扔出去——可惜办公室在25楼,物业不允许高空抛物。
但吐槽归吐槽,活儿还得干。作为一个 DevOps 工程师(虽然现在被迫写业务代码),我深知“安全不是功能,而是底线”。更何况,再过两周就是季度技术分享会,我还打算拿这个项目去装个逼。于是,我咬咬牙,打开了 Spring Security 的官方文档……然后又关了。说实话,那玩意儿比《量子力学导论》还劝退。
不过别慌!经过三天两夜的踩坑、Google、Stack Overflow 和无数次重启应用,我终于搞定了一个极简但生产可用的安全认证系统。今天就来手把手带你搭一套,顺便聊聊我在过程中踩过的雷——说不定哪天你面试就被问到这些“陷阱题”。
为什么选 Spring Security?Python 不香吗?
先说清楚:我们后端是 Java 技术栈,Spring Boot 是主框架。虽然我个人最近沉迷 Rust(真香警告⚠️),也写过不少 Python 脚本做运维自动化,但在这个项目里,用 Django 或 FastAPI 显然不合适——团队没人维护,CI/CD 流水线也不支持。
Spring Security 虽然配置复杂,但它和 Spring Boot 天然集成,社区资源丰富,而且——最重要的是——它能扛住生产流量。去年双11期间,隔壁组用自研鉴权中间件崩了三次,最后还是靠 Spring Security 救场。血的教训啊!
另外,现在很多大厂的 Java 后端面试题里都会问:“你怎么实现 RBAC 权限控制?”、“JWT 和 Session 的区别是什么?”——如果你连 Spring Security 的基本流程都说不清,简历可能直接进碎纸机。
所以,别再幻想用一行 Flask 代码搞定安全了。现实很骨感。
快速上手:三步搭建基础认证
废话不多说,直接上干货。我们的目标是:
- 用户通过用户名/密码登录
- 登录成功后返回 JWT Token
- 后续请求携带 Token 访问受保护接口
- 支持基于角色的权限控制(比如 ADMIN 才能删用户)
第一步:依赖引入
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
💡 小贴士:别用太老的 jjwt 版本,否则会有兼容性问题。我就因为用了 0.9.x,在生成 token 时莫名其妙报
SignatureException,debug 到凌晨两点才发现是版本锅。
第二步:用户实体与服务
我们用最简单的内存用户(实际项目请对接数据库):
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 实际项目这里查 DB,比如 UserRepository.findByUsername(username)
if ("admin".equals(username)) {
return User.builder()
.username("admin")
.password(passwordEncoder().encode("123456")) // 别用明文!
.roles("ADMIN")
.build();
} else if ("user".equals(username)) {
return User.builder()
.username("user")
.password(passwordEncoder().encode("123456"))
.roles("USER")
.build();
}
throw new UsernameNotFoundException("用户不存在");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 强烈建议用 BCrypt
}
}
🚨 安全提醒:永远不要存储明文密码!BCrypt 是目前最推荐的加密方式,自带 salt,防彩虹表攻击。
第三步:核心配置 —— SecurityConfig
这才是重头戏。Spring Security 的精髓在于 Filter Chain。我们需要关闭默认的表单登录,启用 JSON 登录,并集成 JWT 校验。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离项目通常禁用 CSRF
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/actuator/**").permitAll() // 健康检查放行
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 只有 ADMIN 能访问
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
注意几个关键点:
STATELESS:告诉 Spring 不要创建 Session,符合 JWT 无状态特性addFilterBefore:在标准认证过滤器前插入我们的 JWT 拦截器hasRole("ADMIN"):自动加上ROLE_前缀,所以数据库里存的是ADMIN,但 Spring 会识别为ROLE_ADMIN
JWT 过滤器怎么写?
这是最容易出错的地方。很多人直接 copy 网上的代码,结果 token 过期了还不刷新,或者解析失败直接 500。
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String token = extractTokenFromHeader(request);
if (token != null && JwtUtil.validateToken(token)) {
String username = JwtUtil.getUsernameFromToken(token);
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
private String extractTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
配套的 JwtUtil 工具类负责生成和解析 token:
@Component
public class JwtUtil {
private static final String SECRET_KEY = "mySecretKeyThatIsLongEnoughForHS512"; // 生产环境请用 KMS 管理!
private static final long EXPIRATION_TIME = 86400000; // 24小时
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false; // token 过期或无效
}
}
public String getUsernameFromToken(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token)
.getBody().getSubject();
}
}
🔒 运维视角:SECRET_KEY 绝对不能硬编码在代码里! 我们公司用 HashiCorp Vault + CI/CD 注入,上线前自动替换。你也可以用环境变量,但别提交到 GitHub!
登录接口 & 测试
最后写个登录 Controller:
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword())
);
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).body("用户名或密码错误");
}
UserDetails userDetails = customUserDetailsService.loadUserByUsername(request.getUsername());
String token = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(new JwtResponse(token));
}
}
用 curl 测试一下:
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
拿到 token 后,访问受保护接口:
curl -H "Authorization: Bearer <your_token>" http://localhost:8080/api/admin/users
如果一切顺利,你应该能看到数据;如果是普通用户 token,访问 /admin 接口会返回 403。
常见面试题 & 坑点总结
| 问题 | 正确姿势 |
|---|---|
| 密码怎么加密? | 必须用 BCryptPasswordEncoder,别用 MD5/SHA1 |
| Token 存哪里? | 前端存在 HttpOnly Cookie 最安全,其次才是 localStorage |
| 如何登出? | JWT 无法真正“失效”,解决方案:服务端维护黑名单 or 缩短有效期 |
| 权限粒度怎么控制? | 用 @PreAuthorize("hasRole('ADMIN')") 注解方法级权限 |
| 性能瓶颈在哪? | 每次请求都查 DB?加 Redis 缓存 UserDetails! |
特别提醒:别把 Spring Security 当黑盒用。我见过太多人只会 copy-paste 配置,结果线上被绕过权限。理解 Filter Chain 的执行顺序,比背八股文重要一百倍。
写在最后
搞定这套系统后,我终于能在周五下班前合上电脑。虽然过程痛苦,但收获巨大——不仅搞定了项目需求,还顺手整理了一份 GitHub Gist(别担心,没泄露公司代码 😅),准备下周技术分享用。
说真的,作为 DevOps,我原本以为自己只用管 CI/CD 和监控告警。但现实是,现代 DevOps 必须懂应用层安全。你部署的每一个服务,都可能是黑客的入口。Spring Security 虽然陡峭,但一旦掌握,就是你职业护城河的一部分。
对了,如果你也在学 Rust,欢迎一起交流!我刚用 Actix 写了个玩具版的 Auth 服务,性能吊打 JVM(开玩笑的,别当真)。毕竟,在程序员的世界里,语言之争永远不休,但——能跑就行。
下次见!

评论 0