Spring Security 基础:快速搭建安全认证系统
成都的夏天总是来得猝不及防,上周末还在锦里喝着冰粉看熊猫,这周一就被拉进一个“紧急”项目会议。产品经理说:“咱们这个新平台下周就要给运营团队试用,必须带登录、权限控制,最好还能对接现有的用户体系。”我看了眼日历——离 deadline 只剩 5 天,而团队里后端就我和另一个刚转岗的同事。那一刻,我真的想把咖啡泼在 Jira 任务卡片上。
但没办法,干就完了。作为 Claude Code 的早期尝鲜用户(没错,就是那个主打命令行智能体的工具),我早就习惯了用终端狂敲代码的日子。分布式系统搞过几轮,高并发也踩过坑,但说实话,Spring Security 这玩意儿我一直靠现成脚手架糊弄过去——毕竟以前公司都用 OAuth2 + 自研网关统一鉴权,业务层根本碰不到认证逻辑。
这次不行了。运营系统虽然流量不大,但涉及敏感数据操作(比如批量导出用户信息、修改计费策略),安全这块不能马虎。而且产品还提了个“骚需求”:前端要用 Vue 写管理后台,后端提供 RESTful API,但部分接口要能被 Python 脚本调用(运营同学写的数据清洗脚本)。这下好了,传统的 session-based 认证肯定不行,得上 JWT。
为什么是 Spring Security?而不是自己造轮子?
我知道有些兄弟看到“安全”俩字就热血上头,恨不得手撸 AES + RSA + 防重放攻击。但现实是:你写的加密算法,在黑客眼里可能还不如一张草稿纸。去年双11期间,隔壁组自己实现的 token 刷新机制被渗透测试打出个洞,差点让整个订单系统停摆。运维大哥半夜打电话骂街的场景我还记得清清楚楚。
Spring Security 虽然配置起来有点“魔法”,但它是经过工业级验证的。而且和 Spring Boot 天然集成,starter 一加,基本骨架就出来了。对于我们这种 deadline 驱动的小团队,能用成熟方案绝不重复造轮子——这是我在分布式系统里学到最痛的教训。
项目初始化:Spring Boot + Security Starter
先建个干净的 Spring Boot 项目(我用的 3.2.x):
spring init --dependencies=web,security,data-jpa,h2 my-secure-app
cd my-secure-app
注:H2 是为了本地快速开发,生产环境当然换成 MySQL/PostgreSQL。
加上 spring-boot-starter-security 后,启动应用你会发现:
- 所有接口都被拦住了
- 控制台打印出一串随机密码(形如
Using generated security password: 8a7b6c5d...)
这就是 Spring Security 的“默认安全策略”——开箱即用,但也意味着你啥都干不了。我们需要自定义配置。
核心配置:从表单登录到 JWT
我们的目标很明确:
- 前端(Vue) 通过
/api/auth/login提交用户名密码,返回 JWT Token - 后续请求 在 Header 中携带
Authorization: Bearer <token> - Python 脚本 也能用同一个 Token 访问受保护接口(比如
/api/reports/export)
第一步:禁用默认的表单登录
在 SecurityConfig.java 里干掉那些花里胡哨的默认行为:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离,CSRF 暂不考虑(生产环境需评估!)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/actuator/**").permitAll() // 健康检查放行
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
注意:这里
csrf().disable()是为了简化,实际生产中如果涉及浏览器 Cookie 认证,必须开启 CSRF 防护。但我们纯 API 场景,且用 Authorization Header,风险较低。
第二步:实现 JWT 登录接口
创建 AuthController:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
// 触发认证流程
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
// 生成 JWT
String jwt = tokenProvider.generateToken(authentication);
return ResponseEntity.ok(new JwtResponse(jwt));
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).body("Invalid credentials");
}
}
}
关键点在于 authenticationManager.authenticate() —— 它会委托给 UserDetailsService 去查用户。
第三步:自定义 UserDetailsService
我们用 JPA 存用户(简单起见,只存用户名、密码、角色):
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password; // 实际应存 BCrypt 加密后的
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
}
对应的 UserDetailsServiceImpl:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword()) // 注意:数据库里存的是 BCrypt 密文
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toArray(GrantedAuthority[]::new))
.build();
}
}
别忘了在
PasswordEncoderBean 里指定 BCrypt:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
JWT 工具类:生成与解析
这部分网上代码很多,但要注意几点:
- 不要把敏感信息(如密码)塞进 Token
- 设置合理的过期时间(我们设 2 小时)
- 用强密钥签名(别用 "secret" 这种弱鸡 key)
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration}")
private int jwtExpirationMs;
public String generateToken(Authentication authentication) {
String username = authentication.getName();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true;
} catch (SignatureException | MalformedJwtException | ExpiredJwtException |
UnsupportedJwtException | IllegalArgumentException ex) {
// 日志记录具体错误类型,方便 debug
return false;
}
}
}
对应的 application.yml:
app:
jwt:
secret: ${JWT_SECRET:myStrongSecretKey123!@#} # 生产环境务必用环境变量覆盖
expiration: 7200000 # 2 hours in ms
自定义 JWT Filter:拦截并验证 Token
最后一步,把 Token 验证逻辑插入 Spring Security 过滤器链:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromJWT(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 getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // 去掉 "Bearer " 前缀
}
return null;
}
}
运营场景适配:让 Python 脚本能用!
前面提到,运营同学要用 Python 脚本调用 API。他们习惯这样写:
import requests
# 先登录拿 token
resp = requests.post('http://api.example.com/api/auth/login',
json={'username': 'ops_user', 'password': 'ops_pass'})
token = resp.json()['token']
# 用 token 调用业务接口
headers = {'Authorization': f'Bearer {token}'}
data = requests.get('http://api.example.com/api/reports/export', headers=headers)
只要我们的 JWT 接口符合标准,任何语言都能调——这才是无状态认证的威力。运维同学再也不用求我们给脚本开白名单 IP 了(之前因为用 session,脚本跑在不同机器上总失效,被吐槽到死)。
生产环境踩坑实录
坑 1:Token 刷新机制缺失
上线第一天,运营小妹怒吼:“刚导一半数据,接口突然 401 了!”——原来 JWT 过期了。我们临时加了个 /api/auth/refresh 接口,用旧 Token 换新 Token(需验证旧 Token 未过期且用户存在)。但注意:不要无限续期,否则等于没过期。
坑 2:权限粒度太粗
最初只按 ROLE 控制(如 ROLE_ADMIN, ROLE_OPERATOR),结果产品经理说:“运营 A 只能看华南区数据,运营 B 能看全国”。于是引入 方法级权限:
@PreAuthorize("@reportService.canAccessRegion(#regionId, principal)")
@GetMapping("/reports/{regionId}")
public Report getReport(@PathVariable Long regionId) {
// ...
}
配合自定义 PermissionEvaluator,动态判断用户是否有权访问特定资源。这比硬编码 if-else 清爽多了。
坑 3:日志埋点不足
某次安全审计要求“记录所有敏感操作”。我们在 JwtAuthenticationFilter 里加了 MDC(Mapped Diagnostic Context),把用户名透传到日志:
MDC.put("user", username);
try {
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
这样每条日志都带 user=xxx,排查问题快如闪电。
性能与扩展性考量
虽然运营系统 QPS 不高,但架构上我们做了几件事:
- JWT 解析不查库:Token 自包含,验证只需密钥(除非做黑名单)
- 用户信息缓存:
UserDetailsService查 DB 结果用 Caffeine 缓存 5 分钟 - 权限预加载:登录时把用户角色/权限列表塞进 Token 的
scope字段,避免每次请求查权限表
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Session + Redis | 支持实时踢人 | 需维护状态 | 强管控系统 |
| JWT(无刷新) | 无状态,易扩展 | 无法中途失效 | 内部工具、低风险API |
| JWT + 刷新Token | 平衡安全与体验 | 实现稍复杂 | 大多数 Web 应用 |
我们选了第三种——用短期 Access Token + 长期 Refresh Token 组合。
写在最后
折腾完这套认证体系,已经是周五晚上 9 点。窗外玉林路的小酒馆还亮着灯,我顺手写了篇博客(就是你现在看的这篇)。虽然 Spring Security 的配置看起来啰嗦,但比起自己从零实现,省下的时间足够我多喝两杯冰啤酒。
技术这东西,有时候不是越炫越好,而是刚好解决眼前的问题。运营同学现在每天用 Python 脚本跑报表,再也不用求着我们改后端代码;产品经理也终于闭嘴了——毕竟他连登录界面都没见过,直接甩链接给客户。
如果你也在赶一个“明天上线”的项目,不妨试试这套组合拳。记住:安全不是功能,而是底线。别等线上被拖库了才想起补课。
对了,Claude Code 最近更新了 Security 相关的 CLI 插件,能自动生成 JWT 配置模板。成都的夜生活刚刚开始,而我的终端,还在闪烁着绿色的光。

评论 0