从零开始搭建认证系统:我在Spring Security上踩过的那些坑

码上见山
2025-06-14 03:21
阅读 491

引言

引言

还记得去年年底,我们公司要启动一个全新的业务中台项目。作为后端技术负责人,我第一个要考虑的问题就是用户权限和安全控制。

说实话,在这之前我也用过几次Spring Security,但那都是照着教程配个简单的登录页、加几个注解完事。这次不一样——这次我们要做的是一套完整的权限认证系统,集成JWT、支持多租户、还要对接第三方登录(比如钉钉)。任务来得又急,团队里的兄弟也有几个是新来的,对Security这套东西不熟。

一开始我以为这事儿很简单:“Spring Security嘛,配置一下SecurityConfig,再写个UserDetailsService不就好了?”但真正动手之后才发现,事情没那么简单。

今天这篇文,我想以自己的实际经历为线索,带大家走一遍使用Spring Security搭建基础认证系统的过程。不只是贴代码,更想聊聊在真实项目中遇到的问题,以及我怎么一步步解决它们的。


背景与挑战:我们需要一个灵活可扩展的安全框架

背景与挑战:我们需要一个灵活可扩展的安全框架

新项目是一个面向企业客户的产品管理平台,核心功能是帮助他们集中管理多个App、网站账户。由于涉及到客户敏感数据,安全性必须摆在第一位。

我们的核心需求有:

  • 基于账号密码的标准登录流程
  • JWT Token机制支持无状态认证
  • 支持RBAC权限模型
  • 用户角色/权限可动态配置
  • 支持对接第三方系统统一鉴权(如SSO)
  • 将来可能接入SaaS架构,需预留多租户设计空间

我们最终决定采用 Spring Boot + Spring Security + JWT 的组合,因为:

  • 公司技术栈以Java为主,已有一定Spring生态的积累
  • Spring Security提供了开箱即用的安全控制能力,社区活跃且文档丰富
  • 可通过自定义Filter、AccessDecisionManager等组件实现灵活扩展

但真正的挑战才刚刚开始……


解决方案:Spring Security快速入门实践

API接口文档-1

解决方案:Spring Security快速入门实践

Step 1:引入依赖并搭建骨架

项目的初始化并不复杂。我们在pom.xml中添加了如下依赖:

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

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
</dependency>

然后简单写了两三个核心类:

  • JwtUtils:处理token生成与解析
  • JwtAuthenticationFilter:拦截请求并校验token合法性
  • SecurityConfig:主配置类

Step 2:核心配置 —— SecurityConfig

这是我写的第一版Security配置类,其实已经基本够用了:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> {
                auth.requestMatchers("/auth/**").permitAll();
                auth.anyRequest().authenticated();
            });
        return http.build();
    }
}

这里的关键点:

  • 关闭CSRF保护,因为我们使用的是Token认证而非Session Cookie
  • 设置为无状态会话
  • 自定义JWT过滤器放在UsernamePasswordAuthenticationFilter前面,先做身份识别
  • 接口路径按需放行

这个结构虽然简单,但足够支撑起一个初步可用的安全体系。


实战代码:从头开始实现关键模块

实战代码:从头开始实现关键模块

接下来,我会带你看看几个最常用的代码模块是怎么构建的。

1. JWT工具类

@Component
public class JwtUtils {
    
    private static final String SECRET_KEY = "your-secret-key-here"; // 应该从配置文件读取

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(userDetails.getUsername())
            .setExpiration(new Date(System.currentTimeMillis() + 864_000_00))
            .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
            .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
    }

    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody()
            .getExpiration();
    }
}

💡注意:

  • 不要硬编码密钥(建议使用配置中心或环境变量注入)
  • Token有效期可根据实际需要调整
  • 在生产环境中,应该加入黑名单、签发时间戳验证等功能

2. 自定义过滤器类

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
        throws ServletException, IOException {
        
        String token = getTokenFromRequest(request);
        if (token != null && jwtUtils.validateToken(token, userDetails)) {
            Authentication auth = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }

    private String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

这段代码做了几件事:

  • 从Header提取Token
  • 解析并验证
  • 构建认证对象放入Security上下文中

⚠️ 这里有个常见错误:有些人忘了设置SecurityContextHolder.getContext().setAuthentication(auth),这样后面进行权限判断时就会失败。


遇到的坑与解决方案总结

这一路踩了不少坑,这里我挑几个印象深刻的分享下。

1. 登录接口无法访问?原来是顺序错了!

第一次写完SecurityConfig后,测试登录接口发现居然被拦截了!明明在authorizeHttpRequests中设置了.requestMatchers("/auth/**").permitAll();

最后查了半天发现,是因为我忘记把登录接口放进放行列表之前的位置。如果你自己写了一个登录Filter,记得放到正确的位置。

正确的做法是:
要么在filterChain里主动跳过某些路径; 要么在authorizeHttpRequests中明确允许这些路径访问。

另外一点小技巧:如果不确定哪些Filter被加载了,可以在启动时打印所有注册的Filter。

2. 权限注解失效,原来是缺少@EnableGlobalMethodSecurity

为了后续方便做方法级别的权限控制,我尝试加@PreAuthorize("hasRole('ADMIN')"),结果完全不起作用。

后来查资料才知道,默认情况下Spring Security不会处理这种注解,你必须开启相关支持:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig { ... }

加上这句后,你的方法级权限控制就正常了。

3. 多租户场景下的UserDetailsService冲突问题

我们设计了多租户架构,每个租户都有独立的数据库实例。这时候传统的基于单一源的UserDetailsService就不够用了。

我的做法是:

  • 拦截请求时先解析租户标识(比如从Domain头或者子域名)
  • 动态选择对应的数据源
  • 使用不同的UserDetailsService去查询用户信息

为此我写了个抽象类:

public interface TenantAwareUserDetailsService {
    UserDetails loadUserByUsernameAndTenant(String username, String tenantId) throws UsernameNotFoundException;
}

并通过自定义的TenantContext保存当前租户信息,实现逻辑隔离。


效果评估:我们的认证系统现在是什么样?

现在这套系统已经在线上跑了几个月,表现还算稳定。

主要成果如下:

  • 成功支撑了初期用户量几千级的QPS压力(结合Redis缓存Token黑白名单)
  • 提供了标准的登录、登出、token刷新机制
  • RBAC权限模型满足90%以上的业务权限管控需求
  • 系统易于扩展,新增租户只需配置数据源即可接入

我们也在逐步完善一些增强功能,比如:

  • 结合Spring OAuth2支持更多认证方式(社交登录、手机验证码等)
  • 加入审计日志记录登录行为
  • 完善权限管理后台界面,允许管理员可视化配置角色权限

我的经验建议:别让Security把你困住

服务器部署方案-2

回顾整个过程,几点经验送给大家:

✅ 明确需求,先做好规划

安全不是越重越好,尤其在初创阶段。你得搞清楚:

  • 是要做无状态还是有状态?
  • 是否需要细粒度的权限控制?
  • 后续会不会有多种认证方式? 提前画好架构图、列好需求优先级很重要。

✅ 看懂配置背后干了啥

很多人只会copy粘贴Security配置项,却不知道它到底代表什么含义。建议花点时间阅读Spring Security官方文档的Architecture一节,了解Filter Chain的工作原理。

✅ 不必重复造轮子,但也别盲目依赖框架

像JWT、OAuth2这些模块网上有很多封装库,你可以直接拿来用。但在生产环境下一定要理解其内部机制,比如:

  • Token的有效期策略
  • 刷新Token的安全性如何保障
  • 如果出现大量伪造Token攻击怎么办?

✅ 把认证系统当成基础设施的一部分来维护

认证模块是整个系统的“门卫”。上线后要持续关注以下方面:

  • 日志监控登录异常
  • 配置告警阈值防止暴力破解
  • 定期清理无效token
  • 安全加固(IP白名单、WAF)

写在最后:技术成长是个螺旋式上升的过程

回过头来看,当初的我觉得Security好像有点难,甚至一度怀疑是不是应该选Apache Shiro。但现在回头看,正是那次项目让我真真切切理解了Spring Security这套体系。

有时候我们会觉得某个框架太繁琐、不好用,但当你深入了解它的设计思想以后,你会发现它是如此优雅、可扩展。

希望这篇文章能帮你少踩两个坑,少翻两篇文档。毕竟我们开发者的每一分精力,都值得用在更有价值的地方。如果你有任何疑问或者不同看法,欢迎留言交流,我们一起成长 🤝


作者简介:我在一线互联网公司做过多个高并发平台的安全架构设计,目前专注于Spring生态的技术深耕。如果你也喜欢写代码、聊架构、研究技术趋势,欢迎关注我的公众号/博客,一起进步!

评论 0

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