Spring Security 从零到上线:一个被产品经理逼出来的认证系统

GC观察员
2026-02-12 15:08
阅读 1633

上周五晚上九点半,我正瘫在工位上刷着 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

最热最新
暂无评论
GC观察员Lv.1
0
影响力
0
文章
0
粉丝