从零搭建 Spring Security 认证系统,我踩过的那些坑与收获

小而美开发者
2025-06-29 15:43
阅读 793

开篇:为什么选择写这篇关于 Spring Security 的文章?

开篇:为什么选择写这篇关于 Spring Security 的文章?

在我这几年的 Java 全栈开发经历中,Spring Security 是一个绕不开的话题。无论是做内部管理平台、微服务架构下的认证授权中心,还是 ToC 的用户系统,安全机制始终是第一道防线

最近一次项目中,我们团队接到一个任务:为一个新的 SaaS 平台快速搭建一套可扩展的身份认证体系。时间紧、需求变化快,我们需要在两周内完成原型并上线演示版本。

这个时候,我第一个想到的是——用 Spring Security + OAuth2 来搞定核心认证流程。虽然之前也踩过不少坑,但这次经验尤其值得总结。于是就有了这篇文章。

希望这篇文章能给刚接触 Spring Security 或者想快速上手的同学们带来一些实用的经验和思路。


问题描述:项目背景与挑战

问题描述:项目背景与挑战

我们的项目是一个面向中小企业的 SaaS 平台,目标是为企业提供统一的身份登录入口,并集成后续的数据分析、CRM、客户管理等多个子系统模块。

项目初期有三个核心诉求:

  1. 实现基本的用户名+密码登录;
  2. 后续需要对接第三方平台(如钉钉、企业微信)的认证;
  3. 需要区分不同角色权限(普通员工、管理员、超级管理员);

听起来不复杂?其实不然。当时我们遇到几个关键问题:

  • 如何设计数据库结构来支持灵活的角色权限?
  • Spring Security 配置太复杂了,各种 Filter、Provider 怎么理解?
  • 登录接口老是返回 403 或 401,找不到具体原因;
  • 想把 JWT 和 Session 结合起来用,但网上资料五花八门,选型困难;
  • 单元测试怎么覆盖这些认证逻辑?

这些问题看似常见,但在实际操作中很容易陷入细节的泥潭。


解决方案:基于 Spring Security 的分层认证设计

解决方案:基于 Spring Security 的分层认证设计

我们最终选择了这样的技术栈:

  • Spring Boot 2.7.x
  • Spring Security 5.x
  • Spring Data JPA(MySQL)
  • OAuth2(用于后期扩展)
  • JWT + Redis(短期 Token 管理)

整个系统的认证流程大致如下:

用户 -> 登录接口 -> 身份验证成功 -> 生成 JWT + 存入 Redis -> 带着 Token 请求其他接口

同时,我们设计了一个灵活的角色模型,支持 RBAC(基于角色的访问控制)模式。

接下来我会结合代码讲解实现细节。


代码实践:一步步搭建基础认证体系

第一步:引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.11.5</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

第二步:设计用户和角色实体类

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
}

@Entity
public class Role {
    @Id
    private String name; // 如 ROLE_ADMIN, ROLE_USER
}

第三步:自定义登录接口和 JWT 工具类

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );
        String token = jwtUtil.generateToken(authentication);
        return ResponseEntity.ok().header("Authorization", "Bearer " + token).build();
    }
}

JWT 工具类这里就不贴完整实现了,主要功能包括:

  • 生成 Token(带用户名、角色信息等 claim)
  • 解析 Token 并提取认证信息
  • 校验签名是否有效

第四步:配置 Spring Security

这是最核心的部分,也是最容易出错的地方。

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers("/api/auth/login").permitAll()
            .anyRequest().authenticated();
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
}

这个配置文件干了几件事:

  • 禁用了 CSRF(适用于前后端分离项目)
  • 使用无状态会话(配合 JWT)
  • 添加了 JWT 拦截器到过滤链中
  • 放开 /login 接口访问限制
  • 设置密码加密方式(BCrypt)

第五步:实现 JWT 拦截器

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        
        final String authorizationHeader = request.getHeader("Authorization");
        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            try {
                username = jwtUtil.extractUsername(jwt);
                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    if (jwtUtil.validateToken(jwt, userDetails)) {
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            } catch (JwtException e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }
}

踩坑经验:那些调试时崩溃的夜晚

1. 登录总是失败:AuthenticationManager 不工作?

这个问题困扰了我大半天。后来发现是因为我没有注册 AuthenticationManager Bean,或者 UserDetailsService 没有被正确注入。

解决方案: 确保在配置中正确注册 AuthenticationManager bean,并且你的 UserDetailsService 实现类必须标注为 @Service,并在加载时注册进 Spring 容器。

2. 权限不起作用,明明配置了 hasRole('ADMIN') 却可以访问?

注意 Spring Security 中对 role 的前缀处理,默认会自动加上 "ROLE_"。例如:

.antMatchers("/admin/**").hasRole("ADMIN") 
// 实际等价于 ROLE_ADMIN

所以你存储角色的时候不要加 ROLE_ 前缀,Spring 会帮你自动拼接。

如果你使用 hasAuthority() 则不会自动加前缀,你需要明确写出完整的 authority 名称。

3. 登录成功后跳转失败或直接 403?

这一般是由于没有正确设置登录成功的回调 handler。可以重写 successHandler 或者直接返回 JSON 数据。

我们在实际开发中选择了直接返回 JSON 数据,省去页面跳转相关的配置。

4. 单元测试怎么写?

Spring 提供了 MockMvc,你可以模拟认证请求,比如:

mockMvc.perform(post("/api/auth/login")
        .content("{ \"username\":\"test\", \"password\":\"test\" }")
        .contentType(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    .andReturn();

但要注意:每次测试完都要清空 SecurityContext,防止影响下次测试。


效果总结:落地后的收益和提升

项目上线之后,我们获得了以下几个显著的好处:

  • 快速构建了一套可扩展的认证系统,后续接入 OAuth2 只需增加几个 Filter 即可;
  • 用户角色清晰,权限控制粒度细化到接口级别;
  • 系统安全性大大增强,未出现一次账号越权访问问题;
  • 登录性能稳定,单节点 QPS 约 3k 左右;
  • 日志齐全,便于排查异常行为。

此外,在运维方面我们也积累了一些经验:

  • 频繁登录失败 IP 会被触发频率限制(Redis+IP计数器);
  • 登录成功后会记录审计日志,用于后期追溯;
  • 使用 APM(如 SkyWalking)监控认证链路性能。

经验分享:写给正在路上的你

✅ 架构建议:

  • 把认证中心抽出来作为独立服务,有利于微服务治理;
  • 使用 Redis 缓存 Token,支持吊销/刷新;
  • 多租户系统考虑使用多级缓存(本地 Caffeine + Redis)提升性能;
  • 对敏感操作加入双因素认证(2FA)或短信验证码。

💡 小贴士:

  • 不要重复造轮子,Spring Security 社区文档是最权威的参考资料。
  • 多打印日志!尤其是 Filter 链执行顺序和 SecurityContext 的变化。
  • 遇到问题先看 Spring Security 自动配置的内容,再决定是否手动修改。
  • 如果你是新手,建议从最简单的表单登录开始练手,逐步深入。

🧭 展望未来:

随着云原生和微服务的发展,身份认证的边界也在不断演化。像 OAuth2 + OpenID Connect、Keycloak、Auth0、Casdoor 这样的产品或协议,已经成为行业主流。我们在本次项目中打好了基础,也为下一步接入外部认证做好了准备。


结语

负载均衡配置-1

写这篇文章的过程中,我仿佛又回到了那个调试深夜、咖啡喝到第三杯、终于看到 “Login successful” 的瞬间。

Spring Security 是一个强大的工具,但也因为其灵活性而显得“难啃”。不过只要我们从实际业务出发,一步步搭建,就一定能够掌握它。

如果你也在学习 Spring Security,不妨动手试试上面的例子。有问题欢迎留言讨论!

希望我的实战经验对你有所帮助。下期见 👋


📌 附:GitHub 示例仓库地址(假设存在)

你可以在这个 Repo 中找到完整的示例工程: 👉 https://github.com/zhangsan/spring-security-demo

(注:以上内容基于真实项目重构简化而来,部分敏感信息已脱敏处理。)

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝