Spring Security 从零到上线:一个被产品经理逼出来的认证系统
上周五晚上九点半,我正瘫在工位上刷着 GitHub Trending,想着周末要不要去三里屯那家新开的精酿酒吧坐坐——结果企业微信突然弹出一条消息:“小张,咱们新项目下周就要 demo 了,用户登录还没做?”
我一口冰美式差点喷出来。这项目是公司今年重点孵化的 SaaS 工具平台,原本说好先做 MVP,认证模块用现成的 Auth0 搞定。结果产品经理临时改需求,说“客户担心第三方依赖,要求自研登录”。行吧,又是熟悉的“敏捷开发”节奏。
作为技术中台团队的一员,我日常主要负责前端交互和微前端架构,但架不住人手紧缺,后端安全模块也得顶上。好在我 Mac 上还开着去年双11期间研究 Spring Security 的笔记(没错,就是那个凌晨三点线上登录接口被刷爆、我边喝红牛边改配置的夜晚)。
今天这篇教程,就带大家从零搭建一个生产可用的 Spring Security 认证系统。不讲理论八股,直接上代码、踩坑、调优,全是我在北京地铁13号线通勤一小时+加班两小时攒下的实战经验。
为啥不用 OAuth2.0 或 JWT 直接开干?
很多同学第一反应是:“直接上 JWT 不就完了?” 确实,JWT 简单、无状态、适合前后端分离。但我们的项目有这几个硬性要求:
- 需要支持多租户(不同企业客户数据隔离)
- 要求管理员能强制下线用户
- 登录日志必须完整记录(审计合规)
- 用户权限变更后需立即生效
这些需求意味着必须维护服务端会话状态。JWT 一旦签发,在过期前无法撤销,除非引入 Redis 做黑名单——那不如直接用 Session + Token 双保险。
所以我们最终选择了 Spring Security + Redis Session + 自定义 Token 机制 的组合拳。既保留了传统 Session 的可控性,又通过 Token 支持无 Cookie 的 API 调用(比如移动端或第三方集成)。
项目骨架:5 分钟初始化
首先,新建一个 Spring Boot 项目(我用的是 3.2.0,JDK 17)。关键依赖如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
注意:这里没加 spring-boot-starter-oauth2-resource-server,因为我们不做标准 OAuth2,而是自定义 Token 流程。
核心设计:用户体系与权限模型
数据库设计上,我们简化了经典 RBAC 模型,但保留了扩展性:
| 表名 | 说明 |
|---|---|
sys_user |
用户表,含 username, password(BCrypt), status, tenant_id |
sys_role |
角色表,如 ADMIN, USER, GUEST |
sys_permission |
权限点,如 "user:read", "report:export" |
sys_user_role |
用户-角色关联 |
sys_role_permission |
角色-权限关联 |
关键点:tenant_id 字段贯穿所有业务表,实现数据隔离。登录时不仅要校验账号密码,还要确认该用户属于当前请求的租户(通过 Header 传入 X-Tenant-ID)。
第一坑:默认配置太“安全”了
Spring Security 开箱即用,但默认行为会让你懵圈:
- 所有接口自动拦截,返回 403
- 自动跳转到
/login页面(但我们是纯 API 项目!) - 密码必须用 BCrypt 加密,但注册接口怎么处理?
解决方法:完全接管配置。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 前后端分离,关掉 CSRF
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.disable()) // 禁用表单登录
.httpBasic(httpBasic -> httpBasic.disable()); // 禁用 Basic Auth
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里有几个细节:
SessionCreationPolicy.IF_REQUIRED:只有登录时才创建 Session,其他 API 调用靠 Token 维持- 显式放行
/api/auth/**,避免死循环 - 关闭所有默认 UI 行为,因为我们只提供 JSON API
自定义登录流程:Token 怎么发?
我们的登录接口 /api/auth/login 接收 JSON:
{
"username": "zhangsan",
"password": "123456",
"tenantId": "corp_001"
}
Controller 层核心逻辑:
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
// 1. 手动构造 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
);
// 2. 注入 tenantId 到 authentication details
token.setDetails(Map.of("tenantId", request.getTenantId()));
// 3. 交给 AuthenticationManager 验证
Authentication authentication = authenticationManager.authenticate(token);
// 4. 生成自定义 Token(UUID)
String customToken = UUID.randomUUID().toString();
// 5. 将认证信息存入 Redis(Key: token_value, Value: authentication)
redisTemplate.opsForValue()
.set("auth:token:" + customToken, authentication, Duration.ofHours(2));
return ResponseEntity.ok(new LoginResponse(customToken));
}
为什么不用 Spring Security 自动的 Session?
因为我们要兼容两种调用方式:
- 浏览器:Cookie + JSESSIONID(由 Spring Session 自动管理)
- App/第三方:Header
X-Auth-Token: xxx(手动解析)
所以在 OncePerRequestFilter 中统一处理:
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) {
String token = request.getHeader("X-Auth-Token");
if (token != null) {
Authentication auth = (Authentication)
redisTemplate.opsForValue().get("auth:token:" + token);
if (auth != null) {
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
别忘了在 SecurityConfig 中注册这个 Filter:
http.addFilterBefore(tokenAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
权限控制:注解还是表达式?
我们项目里混合使用了两种方式:
- 方法级权限:用
@PreAuthorize("hasPermission('user:delete')") - URL 级权限:在
SecurityConfig中用requestMatchers(...).hasAuthority("ADMIN")
但要注意:hasPermission 需要自定义 PermissionEvaluator:
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
// 从 auth 中获取用户权限列表
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
return authorities.stream()
.anyMatch(a -> a.getAuthority().equals(permission));
}
// ... 其他重载方法
}
然后在配置中启用:
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new CustomPermissionEvaluator());
return handler;
}
生产环境踩坑实录
坑1:Redis Session 序列化问题
默认情况下,Spring Session 用 JDK 序列化,导致 Redis 里存的是二进制乱码,且跨语言读取困难。我们改用 JSON:
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
但注意:反序列化时需要暴露具体类,否则会报 ClassNotFoundException。解决方案是在 ObjectMapper 中注册可信包:
GenericJackson2XmlRedisSerializer serializer = new GenericJackson2XmlRedisSerializer(
objectMapper -> objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
).addMixIn(SimpleGrantedAuthority.class, SimpleGrantedAuthorityMixin.class)
);
坑2:并发登录挤占
产品经理要求“同一账号只能一处登录”。实现思路:
- 用户登录时,生成唯一 Token
- 同时将该用户 ID 与 Token 的映射存入 Redis:
SET user:tokens:{userId} {token} - 下次登录时,先查旧 Token,调用
SessionRegistry.removeSessionInformation()主动失效
但注意:SessionRegistry 默认只对基于 Session 的认证有效。我们的 Token 方案需要手动维护“用户-Token”关系,并在每次请求时校验 Token 是否仍为最新。
坑3:密码加密强度
测试同事用弱密码 123456 注册,结果 BCrypt 加密后存库,登录时却提示“密码错误”。排查发现:前端为了“安全”偷偷做了 MD5!
最后在团队群里咆哮:“前后端约定好,密码只在后端加密!谁再前端哈希我请他喝一周冰美式!” —— 从此天下太平。
性能与监控
上线前,我们做了压测:
| 场景 | QPS | 平均延迟 | 备注 |
|---|---|---|---|
| 未登录访问公开接口 | 4500 | 8ms | 无 Security 开销 |
| 携带有效 Token 访问 | 2200 | 25ms | 主要耗时在 Redis 查询 |
| 登录接口 | 300 | 120ms | 含 BCrypt 加密(故意慢) |
优化点:
- 对 Token 缓存加本地缓存(Caffeine),减少 Redis 访问
- BCrypt 强度从默认 10 降到 8(平衡安全与性能)
- 关键接口添加
@Cacheable避免重复权限计算
另外,通过 Micrometer 暴露指标:
@Timed(value = "security.auth.attempts", extraTags = {"result", "success|failure"})
public Authentication authenticate(...) { ... }
配合 Grafana 看板,实时监控暴力破解尝试。
写在最后
这套方案已在我们 SaaS 平台稳定运行三个月,支撑了 200+ 企业客户、日均 50 万次认证请求。虽然比直接上 Auth0 多花了两周时间,但换来的是完全可控的安全策略和灵活的定制能力——比如最近新增的“扫码登录”、“短信二次验证”,都基于这套底座快速实现。
作为技术中台,我们的使命不是造轮子,而是在合适的时候,把轮子造得更圆一点。下次当你在凌晨三点被报警叫醒,至少能对自己说:“这个坑,我填过。”
对了,今天下班前,产品经理又发来消息:“能不能加个‘记住我’功能?”…… 我默默打开了 Spring Security 的 RememberMe 文档。
(完)
作者:某上市公司技术中台工程师,Mac 键盘上贴着“Do not disturb”的便签,通勤路上靠听《代码搬运工》播客续命。最近在研究如何用 Web Animation API 让登录按钮的 loading 动画更丝滑。

评论 0