Spring Security 基础:快速搭建安全认证系统
上周五凌晨 2 点,我盯着屏幕上一行报错 AccessDeniedException: Access is denied,手边的瑞幸已经凉透。这是我在深圳腾讯系某厂做后端开发的第 18 个月,也是我第一次在生产环境里被权限问题“背刺”。
说来惭愧,作为一个写过分布式锁、调优过 Kafka 消费组的人,我之前一直对 Spring Security 敬而远之。总觉得这玩意儿又重又复杂,配置文件长得像 XML 地狱,而且——我讨厌魔法。直到上个月,产品甩过来一个需求:“加个登录功能,支持手机号+验证码,还要能对接微信和企业微信”,我才意识到:躲不掉了。
更讽刺的是,就在前一周,我还在帮实习生刷 面试题 的时候信誓旦旦地说:“现在谁还用 Spring Security 啊?OAuth2 都直接上 Auth0 或者自研网关鉴权了。” 结果转头自己就得真香现场搭一套。
为什么是 Spring Security?
其实我们团队内部早就有一套基于 JWT 的认证中间件,但那是三年前的老古董了,代码里还留着“Spring Boot 1.x”的注释。这次的需求不仅要支持多端(Web + App + 小程序),还得满足 运营 后台的 RBAC(角色-权限)模型,甚至要预留 SSO 接口。重新造轮子?时间不允许——双 11 大促排期就卡在两周后。
技术选型会上,隔壁组的老张(人称“Spring 老炮”)一句话点醒我:“你不是嫌它重吗?现在 Spring Security 5.x + Spring Boot 3 已经轻量化到可以用 Java Config 全程无 XML 了,连 OAuth2.1 都原生支持。”
行吧,那就试试。反正通宵也习惯了,深圳湾的夜景配上 IDEA 的深色主题,也算浪漫(自嘲脸)。
动手!从零搭建认证骨架
第一步:别被 starter 吓到
很多人一看到 spring-boot-starter-security 就退缩,以为会自动开启一堆拦截器。其实默认行为非常克制——只保护 / 和 /error 路径,其他接口全开放。你可以先加依赖跑起来看看:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动后访问任意接口,你会发现返回 401,但控制台会打印一行临时密码,比如:
Using generated security password: 7a8b9c0d-1e2f-3g4h-5i6j-7k8l9m0n1o2p
这其实是 UserDetailsServiceAutoConfiguration 在作祟。千万别在生产环境留这个!我们马上干掉它。
第二步:自定义 UserDetailsService
我们需要从数据库查用户,而不是用内存账号。假设你有一个 user 表,结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| phone | VARCHAR(11) | 手机号(唯一) |
| password | VARCHAR(100) | BCrypt 加密后的密码 |
| enabled | TINYINT | 是否启用(1=是) |
然后实现 UserDetailsService:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
User user = userRepository.findByPhone(phone)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return org.springframework.security.core.userdetails.User
.builder()
.username(user.getPhone())
.password(user.getPassword()) // 必须是 BCrypt 格式!
.authorities("ROLE_USER") // 后面会动态加载
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(!user.getEnabled())
.build();
}
}
💡 开发心得:密码一定要用 BCryptEncoder 加密!别学我早期项目用 MD5,被安全审计喷到自闭。
第三步:配置 SecurityFilterChain(重点!)
这是 Spring Security 5.7+ 的新范式,用 Java Config 替代 WebSecurityConfigurerAdapter。新建一个配置类:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离可关
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll() // 登录注册放行
.requestMatchers("/actuator/**").hasRole("ADMIN") // 运维监控需管理员
.anyRequest().authenticated()
)
.httpBasic().disable()
.formLogin().disable();
// 添加 JWT 过滤器(后面讲)
http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
注意这里用了 requestMatchers 而不是老版的 antMatchers,语法更函数式。另外,关闭 CSRF 是常见操作,但请确保你的前端有防重放机制。
实现手机号+验证码登录
Spring Security 默认只支持用户名+密码。要扩展登录方式,得自定义 AuthenticationProvider 和 AuthenticationToken。
自定义 Token
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal; // 手机号
private Object credentials; // 验证码
public SmsCodeAuthenticationToken(String phone, String code) {
super(null);
this.principal = phone;
this.credentials = code;
setAuthenticated(false);
}
// ... getter/setter 略
}
自定义 Provider
@Component
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) {
SmsCodeAuthenticationToken token = (SmsCodeAuthenticationToken) authentication;
String phone = (String) token.getPrincipal();
String inputCode = (String) token.getCredentials();
// 1. 从 Redis 校验验证码
String realCode = redisTemplate.opsForValue().get("sms:code:" + phone);
if (!inputCode.equals(realCode)) {
throw new BadCredentialsException("验证码错误");
}
// 2. 加载用户
UserDetails userDetails = userDetailsService.loadUserByUsername(phone);
// 3. 构造已认证 Token
return new SmsCodeAuthenticationToken(
userDetails,
userDetails.getAuthorities()
);
}
@Override
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
在 Controller 中触发
@PostMapping("/login/sms")
public ResponseEntity<?> loginBySms(@RequestBody SmsLoginRequest request) {
SmsCodeAuthenticationToken token =
new SmsCodeAuthenticationToken(request.getPhone(), request.getCode());
Authentication auth = authenticationManager.authenticate(token);
SecurityContextHolder.getContext().setAuthentication(auth);
// 生成 JWT 返回
String jwt = jwtUtil.generateToken(auth);
return ResponseEntity.ok(new LoginResponse(jwt));
}
🚨 踩坑提醒:别忘了在 SecurityConfig 里注册这个 Provider!否则会报
ProviderNotFoundException。我当时卡了两小时,最后发现是漏了@Component。
权限控制:从 RBAC 到运营后台
产品要求运营后台能动态分配菜单权限。我们设计了经典的四表模型:
user:用户表role:角色表(如 OPERATOR, ADMIN)permission:权限表(如 user:read, order:write)role_permission:角色-权限关联表
关键在于 GrantedAuthority 的动态加载。修改 CustomUserDetailsService:
@Override
public UserDetails loadUserByUsername(String phone) {
User user = userRepository.findByPhone(phone).orElseThrow(...);
List<String> permissions = permissionRepository.findPermissionsByUserId(user.getId());
List<GrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return User.builder()
.username(phone)
.password(user.getPassword())
.authorities(authorities)
.build();
}
然后在接口上用注解控制:
@GetMapping("/orders")
@PreAuthorize("hasAuthority('order:read')")
public List<Order> listOrders() {
return orderService.findAll();
}
💡 开发心得:
@PreAuthorize要配合@EnableMethodSecurity使用!别再用过时的@EnableGlobalMethodSecurity了。
性能与运维考量
数据库压力
每次请求都查权限?那肯定不行。我们在用户登录时就把权限列表缓存到 JWT 的 payload 里(注意大小别超 4KB)。验证时直接解析 JWT,避免 DB 查询。
日志与监控
在 Filter 里加日志:
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
try {
// 解析 JWT...
log.info("Auth success: user={}, uri={}", userId, request.getRequestURI());
} catch (Exception e) {
log.warn("Auth failed: uri={}, reason={}", request.getRequestURI(), e.getMessage());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
}
}
}
运维同学特别喜欢这种日志,排查问题快多了。
与 Python 服务的协作
我们有个用 Python 写的风控服务,需要验证用户身份。解决方案很简单:把 JWT 透传过去,Python 用 pyjwt 库校验签名即可。两边共享同一个密钥(通过 KMS 管理),完美解耦。
从抵触到真香:我的转变
说实话,Spring Security 的学习曲线确实陡峭。光是 Authentication、SecurityContext、Principal 这些概念就让我翻了半天源码。但一旦理清主线——认证(Authentication)和授权(Authorization)分离——就豁然开朗。
现在回头看,它提供的不只是安全框架,更是一套安全思维模型。比如:
- 密码加密必须用自适应算法(BCrypt/SCrypt)
- 会话管理要考虑无状态(JWT) vs 有状态(Session)
- 权限粒度要下沉到方法级别而非 URL
这些经验,远比应付 面试题 里的“说说 Spring Security 原理”有价值得多。
最后几句大实话
- 别试图一次性配完美:先跑通最小闭环(比如只做登录),再逐步加 RBAC、OAuth2。
- 测试用 Postman 别用浏览器:浏览器会自动带 Cookie,干扰 JWT 测试。
- 线上一定关掉 Security 的 debug 日志:曾经因为
logging.level.org.springframework.security=DEBUG泄露了用户密码哈希,被安全团队追杀三条街。
在深圳这座加班之城,能少踩一个坑,就能多睡一小时。希望这篇带血泪经验的文章,能帮你避开我走过的弯路。
毕竟,程序员的时间,不该浪费在重复造安全轮子上——除非你是在准备跳槽面试(狗头保命)。
本文完。凌晨三点,我去续杯咖啡了。

评论 0