Spring Security基础:快速搭建安全认证系统
上周五晚上九点半,我盯着屏幕上一堆403 Forbidden的错误日志,内心一万只草泥马奔腾而过。又是产品临时加需求,说是下周就要给客户演示新功能,结果认证这块完全没做。领导还美其名曰:“你不是搞安全的吗?这种东西对你来说不是分分钟的事?”
呵,分分钟?我真想把键盘扔他脸上。
不过说真的,在我们这个云原生团队干了快两年,每天和各种漏洞、渗透测试、安全审计打交道,Spring Security 确实是我最常用的工具之一。尤其是现在微服务架构下,每个服务都要有独立的认证授权机制,Spring Security 配合 OAuth2 或 JWT 几乎成了标配。
今天就趁着周末,把最近踩过的坑整理一下,写个快速上手指南。顺便也给准备跳槽的兄弟们攒点简历素材——毕竟现在面试不问 Spring Security 都不好意思说自己招 Java 后端。
为啥又是 Spring Security?
先说清楚背景。我们组主要做的是一个面向金融客户的 SaaS 平台,技术栈是典型的 Spring Boot + Kubernetes。去年双11期间,因为一个低级的权限绕过漏洞,差点让客户数据泄露(别问,问就是血泪史)。从那以后,老板拍板:所有新项目必须通过安全评审,认证授权模块统一用 Spring Security。
说实话,一开始我对 Spring Security 是又爱又恨。配置复杂、文档晦涩、默认行为反人类……但用熟了之后发现,它真的稳。特别是和 Spring Boot 自动装配一结合,很多东西开箱即用。
至于为什么不用其他方案?比如 Shiro?坦白讲,Shiro 轻量是轻量,但在云原生环境下扩展性差太多。而且现在主流招聘 JD 里清一色写着“熟悉 Spring Security”,你懂的——为了简历好看,也得会。
哦对了,最近还有人问我:“你们做金融的,要不要上区块链?”
我直接笑出声。兄弟,区块链解决的是信任问题,不是认证问题。你让用户登录用私钥签名?怕不是想被用户骂死。认证还是老老实实用 OAuth2 + JWT 吧。
快速搭建:从零到可上线
好了,废话不多说,直接上干货。假设你现在要搭一个新项目,需要支持:
- 用户名/密码登录
- 基于角色的权限控制(ROLE_ADMIN, ROLE_USER)
- 接口级细粒度权限(比如 /api/admin/* 只能 admin 访问)
- 支持 JSON 格式返回错误(而不是跳转 HTML 页面)
第一步:依赖引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
注意:不要同时引入 spring-boot-starter-oauth2-resource-server,除非你明确要用 OAuth2。新手很容易在这里搞混。
第二步:用户实体设计
数据库表很简单,就三张核心表:
| 表名 | 字段说明 |
|---|---|
users |
id, username (唯一), password (BCrypt加密), enabled |
roles |
id, name (如 ROLE_ADMIN) |
user_roles |
user_id, role_id (多对多关联) |
这里有个坑:千万不要明文存密码!我们组去年就因为一个实习生把密码存成明文,被安全扫描工具打了个高危。后来强制要求所有密码必须用 BCryptPasswordEncoder 加密。
代码示例:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
// 从 DB 拿到角色列表,转成 GrantedAuthority
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.isEnabled(),
true, true, true, // accountNonExpired, credentialsNonExpired, accountNonLocked
authorities
);
}
}
第三步:核心配置 —— WebSecurityConfigurerAdapter(已过时?别慌)
我知道 Spring Security 5.7+ 废弃了 WebSecurityConfigurerAdapter,但在生产环境,很多人还在用。原因很简单:新方式(基于组件注册)对新手极不友好,而且文档混乱。
所以我们团队目前还是用旧方式,等 Spring Boot 3 全面普及再迁移。别杠,能跑就行。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // 前后端分离项目通常关掉 CSRF
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll() // 登录接口放行
.antMatchers("/api/admin/**").hasRole("ADMIN") // 注意:这里写 ADMIN,不是 ROLE_ADMIN
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Forbidden\"}");
});
}
// 暴露 AuthenticationManager Bean,用于登录时手动认证
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
重点解释几个地方:
hasRole("ADMIN")会自动加上ROLE_前缀,所以数据库里存ROLE_ADMIN,这里写ADMIN- 关闭 CSRF 是因为我们的前端是 Vue/React,用 Token 认证,CSRF 不适用
STATELESS表示不创建 Session,适合 API 服务- 自定义
authenticationEntryPoint和accessDeniedHandler是为了让错误返回 JSON,而不是跳转登录页
第四步:登录接口实现
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 生成 Token(这里简化为 UUID,实际建议用 JWT)
String token = UUID.randomUUID().toString();
// TODO: 把 token 存 Redis,关联用户信息,设置过期时间
return ResponseEntity.ok(new LoginResponse(token));
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid username or password");
}
}
}
⚠️ 注意:上面的 Token 生成是示意。生产环境强烈建议用 JWT,并配合 Redis 做黑名单(用于登出)。JWT 的好处是无状态、可跨域、自带过期时间。
生产环境踩坑实录
坑1:路径匹配顺序问题
有一次线上事故,/api/user/profile 被拦截了,但明明配置了 .antMatchers("/api/user/**").hasRole("USER")。查了半天发现,Spring Security 的路径匹配是有顺序的!后面的规则不会覆盖前面的。
正确写法应该是从具体到宽泛:
.authorizeRequests()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
如果反过来,/api/user/** 先匹配了,那 /api/admin/** 就永远不会生效。
坑2:BCrypt 加密强度太高导致登录慢
我们有个老系统迁移,用户量大,BCrypt 默认强度是 10。结果压测时发现登录接口 P99 超过 800ms!后来调成 6 才缓解。虽然安全性略降,但在用户体验和安全之间得权衡。
| BCrypt Strength | 加密耗时(毫秒) | 安全性 |
|---|---|---|
| 4 | ~10ms | 低 |
| 6 | ~50ms | 中 |
| 10 | ~400ms | 高 |
| 12 | ~1600ms | 极高 |
建议:新项目用 10,老系统或高并发场景可适当降低。
坑3:跨域(CORS)和安全配置冲突
前端同事经常抱怨:“本地调试 403,但 Postman 能通!”
原因:浏览器发 OPTIONS 预检请求,而 Spring Security 默认拦截了 OPTIONS。
解决方案:在 configure(HttpSecurity http) 里加一行:
.cors().and()
并在配置类里提供 CorsConfigurationSource:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
性能与架构考量
在 K8s 环境下,我们通常把认证服务拆成独立的 auth-service,其他服务作为 Resource Server 验证 Token。这样做的好处:
- 认证逻辑集中,便于审计和升级
- 减少每个服务的依赖复杂度
- 可以单独扩缩容认证服务
但如果你只是小项目,没必要搞这么重。直接在单体应用里集成 Spring Security 更高效。
另外,不要把权限判断放在 Controller 里!比如:
// ❌ 错误示范
@GetMapping("/delete")
public String delete() {
if (!currentUser.hasRole("ADMIN")) {
throw new AccessDeniedException();
}
// ...
}
应该用注解:
// ✅ 正确做法
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/delete")
public String delete() {
// ...
}
记得在配置类开启方法级安全:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}
这样权限逻辑和业务逻辑分离,也方便单元测试。
最后:安全不是功能,是习惯
写这篇文章的时候,我又想起去年那个漏洞。其实根本原因是开发图省事,直接在 Controller 里写 if (userId != currentUserId) return;,结果被人用 ID 遍历搞了。
Spring Security 再强大,也挡不住人写 bug。安全工程师的价值,不是写多少过滤器,而是推动团队建立安全开发流程。
比如我们现在:
- 所有 PR 必须过 SonarQube 扫描
- 敏感操作必须二次验证(短信/邮箱)
- 权限变更必须走审批流
- 每月一次红蓝对抗演练
这些可能比你会不会配 Spring Security 更重要。
结语
Spring Security 上手确实有点门槛,但一旦掌握,你会发现它像一把瑞士军刀——小到登录认证,大到 OAuth2 集成、SAML 单点登录,都能搞定。
如果你正在准备跳槽,建议把这套流程跑通,然后写到简历的“项目经验”里。面试官一问“你怎么做权限控制的”,你就能侃半小时,稳了。
至于区块链?等哪天 Spring Security 官方出个 spring-security-blockchain-starter 再说吧 😂
最后送大家一句话:永远不要相信客户端传来的任何数据,包括用户 ID、角色、甚至 Token。后端必须二次校验。
好了,凌晨一点,咖啡喝完了,该去修下一个 CVE 了。下次见!
作者:某大厂安全工程师,日常和 CVE 斗智斗勇,业余时间研究开源项目源码。K8s 重度用户,坚信“一切皆可容器化”。

评论 0