从零开始搭建安全认证系统:我的 Spring Security 实战经验

#高庆华
2025-06-23 20:49
阅读 206

引子:项目背景与初衷

引子:项目背景与初衷

去年我们团队接了个新项目,是一个企业级的管理系统,涉及到员工信息、考勤打卡、薪资计算这些敏感模块。客户特别强调了数据的安全性,明确提出需要一套完整的权限管理机制,用户必须经过身份验证才能访问系统功能。

一开始,我在技术选型上其实也有过纠结,是继续用 Shiro 还是换成 Spring Security?毕竟公司之前几个项目都是用的 Apache Shiro,大家都熟悉,文档也齐全。但考虑到 Spring Boot 几乎成了标配,而 Spring Security 和 Spring Boot 的整合更顺畅,加上现在主流的 OAuth2、JWT 等安全协议支持得也更好,最终我决定尝试在项目中引入 Spring Security

这篇文章记录的是整个项目搭建安全认证系统的过程,包括踩过的坑和一些关键点的实现思路。如果你也在做类似的工作,或者准备入门 Spring Security,希望这篇分享能让你少走些弯路。


遇到的问题与挑战

遇到的问题与挑战

1. 复杂权限需求 vs 安全框架的适应性

客户提的需求不少,比如:

  • 用户登录后要根据角色展示不同的菜单;
  • 每个接口都有明确的权限控制,不能越权访问;
  • 不同角色之间权限可以灵活配置;
  • 要求支持前后端分离的 JWT token 登录方式;
  • 登录失败要限制次数,防止爆破攻击;
  • 登录成功后记录日志,方便审计。

这些功能听起来都挺常见的,但在一个刚起步的项目里同时满足所有要求,难度还是不小的。尤其是如何把 Spring Security 的权限模型跟我们自己的 RBAC(基于角色的权限控制)模型对接好,这成了初期最大的痛点。

2. 多种认证方式共存问题

项目早期我们只做了表单登录,但随着前后端分离架构落地,前端不再依赖模板渲染,而是完全独立的 Vue 前端应用。这就意味着我们需要支持两种认证方式:

  • 传统的 formLogin 表单登录(主要用于后台管理员)
  • JWT Token 的无状态登录(用于前后端分离场景)

两种认证机制在 Spring Security 中并行工作,导致我们在 Filter 配置和异常处理上频频踩坑。

3. 第三方集成和扩展成本高

后来客户又提出,希望未来能接入 LDAP、OAuth2 第三方登录等方式。虽然 Spring Security 提供了一些扩展点,但在实际使用中发现很多细节不透明,文档不够清晰,光是查阅资料就耽误了不少时间。


解决方案:Spring Security + 自定义策略

解决方案:Spring Security + 自定义策略

技术选型

我们的整体技术栈是:

  • 后端:Spring Boot + Spring Security
  • 数据库:MySQL + MyBatis Plus
  • 前端:Vue3 + Element UI
  • 认证方式:Form Login + JWT Token
  • 权限管理:RBAC 模型 + 动态权限过滤

为了兼容传统登录方式和前后端分离,我们采用了双认证模式设计。通过两个不同的 Filter Chain 实现不同路径的安全策略。

架构设计思路

大致结构如下:

Browser         Mobile App        Third Party APIs
  |                |                    |
[Form Login]   [JWT Auth]       [OAuth2 / SSO]
     \            /                       \
      \          /                         \
       ↓        ↓                           ↓
    Spring Security (多FilterChain模式)
           ↓
       RBAC权限验证
           ↓
     接口业务逻辑

在 Spring Security 的配置中,我们主要做了这几件事:

  1. 划分 URL 权限等级:将 /admin/** 划分为需要登录且有角色的角色访问,/api/** 使用 JWT Token 认证。
  2. 自定义 UserDetailsService:对接数据库中的用户信息。
  3. JWT Token 支持:在请求头中解析 Token,进行鉴权。
  4. 动态权限控制:实现可配置的接口级别权限管理。
  5. 登录失败处理 + 日志记录:增强系统的可观测性和安全性。

关键代码实践

下面我会以一个最小可运行的例子展示核心代码结构,省略部分配置,保留最核心的部分。

1. 自定义 UserDetails 实现类

public class CustomUserDetails implements UserDetails {
    private String username;
    private String password;
    private boolean accountNonExpired = true;
    private boolean credentialsNonExpired = true;
    private boolean accountNonLocked = true;
    private Collection<? extends GrantedAuthority> authorities;

    // get/set methods omitted
}

2. 实现 UserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = userService.getByUsername(username);
        if (sysUser == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }

        return new CustomUserDetails(sysUser);
    }
}

3. 配置 SecurityConfig(简化版)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .and()
            .formLogin()
            .loginPage("/login")
            .defaultSuccessUrl("/admin/home")
            .failureUrl("/login?error=true")
            .and()
            .logout()
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login");

        return http.build();
    }

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/api/**")
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests()
            .anyRequest().authenticated();

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
        return provider;
    }
}

4. JWT 认证 Filter 示例

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = extractToken(request);

        if (token != null && validateToken(token)) {
            Authentication auth = getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }

    // 提取Token、校验、生成Authentication等方法略去...
}

踩过的坑与解决思路

1. 多个 FilterChain 冲突问题

刚开始我们用了 @Order 注解来区分 FilterChain 的顺序,结果发现某些请求被放行了,某些明明匹配不到路径却进去了。

解决办法:

  • 显式使用 .securityMatcher() 来绑定每个 FilterChain 对应的路径;
  • 使用不同的 http.authorizeRequests() 策略;
  • 避免多个链共享同一个 http.formLogin() 配置。

2. 动态权限加载性能问题

一开始我们每次请求接口都要重新查询数据库获取用户的权限列表,这明显会影响性能。尤其在并发量大的时候,接口响应慢了一大截。

优化方案:

  • 缓存权限信息:使用 Redis 存储用户角色和权限映射;
  • 加入本地缓存(如 Caffeine),避免频繁访问 Redis;
  • 增加权限更新通知机制,保证缓存一致性。

3. 登录失败锁定策略实现

虽然 Spring Security 本身没有提供“连续失败多少次后锁定账户”的机制,但我们自己可以在数据库中维护一个 failed_attempts 字段,并结合 Redis 进行实时控制。

实现要点:

  • 每次登录失败时更新该字段;
  • 达到阈值后记录锁定时间;
  • 登录前先检查是否处于锁定状态;
  • 使用 Redis 设置滑动窗口限制(例如每小时最多 10 次登录失败)。

4. 跨域(CORS)引发的权限异常

由于前端用的是独立部署的 Vue 应用,跨域问题一度让我们头疼。Spring Security 默认会拦截 OPTIONS 请求,导致前端发起的 POST 请求无法正确触发 CORS 协商。

解决方案:

在 Security 配置中开启跨域支持:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.cors();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("*"));
    configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

成果回顾与系统收益

这个项目上线已经一年多了,Spring Security 整体表现非常稳定。通过这套安全体系,我们实现了以下目标:

  • 所有用户操作都需通过认证,保障了系统的安全性;
  • 权限配置灵活,可以通过界面配置接口级别的访问控制;
  • 支持多种认证方式,适应不同的使用场景;
  • 系统具备良好的扩展性,为后续接入 SSO、OAuth2 打下了基础;
  • 登录日志、失败记录完备,方便后续审计和运维。

更重要的是,整套系统并没有因为增加安全层而导致性能下降。经过压测,在并发量达到 200 QPS 的情况下,认证层平均响应时间仍控制在 2ms 左右。


我的经验建议

如果你正准备在项目中引入 Spring Security 或者正在使用它遇到了问题,我可以给你几个实用的小建议:

1. 不要死磕文档,动手才是王道

Spring Security 的文档内容丰富,但很多时候你真正理解某个机制的方式就是——写个 Demo 跑一遍。

你可以新建一个 Spring Boot 项目,把你想测试的功能写进去,跑起来看看行为是不是符合预期。

2. 分清楚认证与授权的区别

很多人一上来就把 Security 当作“只能用来登录”,其实它的功能远不止于此。

  • 认证(Authentication):你是谁?
  • 授权(Authorization):你能做什么?

这两部分可以分开设计,甚至由不同的模块负责处理。特别是在 RBAC 场景下,这一点尤为重要。

3. 尽早考虑扩展性

不要一开始就搞得很复杂,但一定要预留扩展空间。比如:

  • 接口级别的权限可以抽象成 Permission 类;
  • 用户、角色、权限的关系可以做成关联表;
  • 权限校验逻辑抽离出来,便于后期接入 RABC、ABAC 模型;
  • 如果以后要考虑多租户、OAuth2,不妨预留适配器接口。

4. 别忽视异常处理和日志追踪

安全相关的错误,比如未认证或权限不足,往往会返回一堆模糊的 401/403 错误码。这对于调试是非常痛苦的。

建议:

  • 统一封装 Security 相关的异常输出;
  • 在全局异常处理器中捕获 AccessDeniedExceptionAuthenticationException
  • 添加请求上下文的日志追踪 ID(MDC),便于定位问题。

5. 生产环境别忘了安全加固

  • 关闭 Debug 页面和默认的 /actuator 端点访问权限;
  • 配置登录失败尝试次数限制;
  • 使用 HTTPS 加密传输;
  • 定期轮换密钥(特别是 JWT 的 signingKey);
  • 开启安全日志审计,对敏感操作记录用户 IP、时间、动作等信息。

结语:安全不是终点,而是起点

作为开发者,我们总是在追求更高的性能、更好的体验、更快的开发效率。但有时候,我们会忽略系统的基本安全防线。

通过这次项目实战,我深刻体会到,一个好的安全系统不是靠“防御”建立起来的,而是通过细致的设计、良好的架构、持续的运维来构建的。

Spring Security 是一个强大而复杂的框架,但它并不是万能的。真正的系统安全,永远离不开我们对业务的理解和对风险的把控。

如果你问我:“Spring Security 难吗?”我会说:“难的是如何把它用得恰到好处。”

最后,送大家一句我常挂在嘴边的话:“代码是冰冷的,架构是有温度的。”愿你在每一次技术选择中,都能找到属于自己的那份“安全感”。


如果觉得这篇文章对你有帮助,欢迎点赞、收藏,也欢迎留言交流你的 Spring Security 实战经验。

评论 0

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