从0开始用Spring Security构建安全认证系统:我的踩坑与成长之路

神奇的月亮
2025-06-27 12:54
阅读 758

开头:为什么要写这篇博客?

开头:为什么要写这篇博客?

作为一名工作5年的后端开发工程师,我经历过从传统单体架构到微服务、再到如今的云原生时代的变迁。在各种项目中,有一个模块几乎是每个系统都绕不开的——用户权限控制和安全认证模块

记得刚入行时,我负责的第一个任务就是“做一个登录接口”。当时我觉得这事儿简单极了——校验用户名密码,返回个 token 就完事了。然而现实给了我当头一棒,不仅有权限分级、token 刷新机制的问题,还因为没有做好安全设计导致系统被攻击……那一次失败让我彻底认识到:安全不是功能,而是一种责任。

从那时起,我开始认真学习 Spring Security,也慢慢把它变成自己后端技术栈中的“标配组件”。今天我想结合几个真实项目的经历,分享一下我是如何一步步用 Spring Security 快速搭建出一个稳定、灵活且可扩展的安全认证系统。


项目背景:一个内容管理平台的身份认证需求

项目背景:一个内容管理平台的身份认证需求

去年我们团队接到一个新的项目——给一家教育机构搭建一个内容管理系统(CMS),主要供内部编辑和运营使用,用于发布课程、管理文章资源等内容。由于这是面向内部员工使用的系统,所以用户数量不算太大(千级以内),但安全性和操作日志要求非常高。

我们的项目目标之一是:

搭建一个基于RBAC(基于角色的访问控制)模型的安全认证系统,支持多角色、细粒度的接口权限控制,并能集成审计日志。


遇到的挑战:权限模型复杂 + 扩展性差的传统做法

遇到的挑战:权限模型复杂 + 扩展性差的传统做法

一开始,我们尝试用传统的“手动拦截器”来实现权限控制。比如通过自定义注解 + AOP 的方式做权限判断:

@Permission("article:read")
public ResponseEntity<?> getArticle(Long id) {
    // ...
}

这种方式初期看起来没什么问题,但随着业务增长,接口越来越多,权限规则越来越复杂:

  • 接口之间的组合权限怎么处理?
  • 权限规则修改频繁,每次都要改代码重新上线?
  • 不同的角色需要继承不同的权限,怎么设计数据结构?
  • 缺乏统一的安全策略配置?

这些问题让我们意识到,手写的权限验证已经难以为继。我们迫切需要一套标准、成熟、易于扩展的安全框架来支撑整个系统的安全性。

于是,我们决定引入 Spring Security + JWT 构建完整认证授权体系。


解决方案:用Spring Security构建安全认证层

最终我们采用的技术栈如下:

  • Spring Boot 2.7.x
  • Spring Security 5.7.x
  • JWT + Redis 实现无状态会话管理
  • 使用数据库存储用户角色、权限信息
  • 基于方法级别的细粒度权限控制

这个方案的目标是做到以下几点:

  1. 支持多角色权限分配,动态管理权限;
  2. 接口级权限控制,包括方法级别;
  3. 支持权限继承,提高灵活性;
  4. 登录过程安全可靠(防暴力破解、密码加密等);
  5. 支持审计日志记录用户行为。

下面我以实际项目为例,介绍一下搭建的核心流程。


实践细节:Spring Security认证授权是怎么落地的?

数据流转过程-1

1. 安全配置类的基本搭建

最基础的入口当然是 SecurityConfig 配置类。这里我们需要重写 configure(HttpSecurity http) 方法:

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

重点说明:

  • prePostEnabled = true 是为了支持我们在 Controller 中使用 @PreAuthorize("@permission.check('xxx')") 这种注解;
  • 使用了 JWT 和 Redis 管理会话,所以启用了 STATELESS 模式;
  • 自定义过滤器 JwtAuthFilter 会在后面详细解释。

2. JWT身份验证流程设计

我们将用户的登录信息通过 JWT Token 传输,避免服务端保存会话状态,适合分布式部署。Token 主要包含以下内容:

  • 用户ID(userId)
  • 角色列表(roleIds)
  • 过期时间(exp)

登录成功后返回类似这样的 Token:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjMiLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sImV4cCI6MTcxODA4NTEyNH0.qQfT...

验证逻辑放在自定义的 JwtAuthFilter 过滤器中,大致逻辑如下:

public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        String token = getTokenFromRequest(request);
        if (token != null && jwtService.validateToken(token)) {
            String userId = jwtService.getUserIdFromToken(token);
            List<String> roles = jwtService.getRolesFromToken(token);
            
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                userId, null, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())
            );
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        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;
    }
}

3. 权限控制:从静态走向动态

最初我们尝试的是硬编码权限检查:

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/page")
public String adminPage() {
    return "admin";
}

这种做法在权限较少时还能应付,但一旦权限数量上来就很难维护。后来我们采用了动态权限控制:

  • 把所有权限名称存在数据库中(如:user:create,course:delete)
  • 在启动时加载权限表至内存缓存(Redis 或本地 HashMap)
  • 使用自定义的 PermissionService 来进行运行时权限检查:
@Component("permission")
public class PermissionService {
    
    public boolean check(String permission) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

        return authorities.stream().anyMatch(a -> a.getAuthority().equals(permission));
    }
}

然后在 Controller 中这样使用:

@PreAuthorize("@permission.check('content:publish')")
@PostMapping("/publish")
public ResponseEntity<?> publishContent(@RequestBody ArticleDTO dto) {
    // 发布逻辑
}

这样一来,权限就可以由管理员在后台动态配置,前端根据当前用户拥有的权限控制菜单显示,整个系统变得更灵活。


踩过的坑 & 经验总结

1. CORS 和 OPTIONS 请求搞不定?

刚开始整合前后端分离之后,出现了大量跨域请求报错,特别是 /login 接口。

解决办法其实很简单,在 SecurityConfig 中加入如下配置即可允许 OPTIONS 请求放行:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
       .authorizeRequests()
       .antMatchers("/api/public/**").permitAll()
       .anyRequest().authenticated();
}


![API接口文档-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025062712/2f7b5e23-9d8c-422f-887e-8bb3e41da5b5.jpg)


@Bean
public CorsConfigurationSource corsConfigurationSource() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true);
    config.addAllowedOrigin("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    source.registerCorsConfiguration("/**", config);
    return source;
}

2. 自定义异常无法全局捕获?

一开始我们自定义了多种认证异常,希望通过 @ControllerAdvice 全局处理,却发现有些异常跳过了异常处理器。

原因在于,Spring Security 的过滤链抛出的异常不经过 controller 层,自然也无法触发全局异常处理。解决办法是自定义一个认证异常处理类并加入到过滤链中:

@Component
public class AuthExceptionEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        Map<String, Object> result = new HashMap<>();
        result.put("code", HttpStatus.UNAUTHORIZED.value());
        result.put("message", "未授权:" + authException.getMessage());

        new ObjectMapper().writeValue(response.getOutputStream(), result);
    }
}

并在 SecurityConfig 中注册它:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthExceptionEntryPoint exceptionHandler) throws Exception {
    return http
        .exceptionHandling().authenticationEntryPoint(exceptionHandler)
        .and()
        // ...其他配置
        .build();
}

3. Redis缓存失效导致token无效?

我们在早期使用 Redis 缓存 JWT 的黑名单(用来快速吊销 token),但某天运维同学执行了 flushall……

结果,一些用户明明还在登录状态,却突然被踢下线。为此我们进行了优化:

  • 引入 TTL 机制,设置合理的 token 生命周期(例如1小时+刷新机制)
  • 黑名单只记录短期强制下线的情况
  • 增加登录态自动续期机制,防止频繁掉线影响体验

效果与收获:不只是“做了个认证系统”

经过这套架构的实施后,效果还是非常明显的:

指标 改造前 改造后
安全漏洞 月均1~2次 0次
接口权限变更效率 至少1次代码修改、发版 后台动态更新
权限复用率 几乎无复用 支持角色继承、组合
用户并发测试响应时间 ~300ms ~120ms(去掉 DB 查询)

更重要的是,团队内的后端开发者们开始统一使用这一套认证授权体系,大家的开发效率也提升了。

现在我们再接新项目时,只要引入一个 starter 模块,就能完成大部分的权限相关配置,真正做到了“开箱即用”。


给新手的一些建议

  1. 别一开始就追求完美:如果你只是做个小型项目,可以先用默认的 UserDetailsService + 内存账号试试;
  2. 了解原理比死记API更重要:比如理解 Filter Chain、AuthenticationManager 的作用;
  3. 不要忽视性能问题:权限校验每秒可能被执行上千次,尽量减少 DB 查询频率;
  4. 安全是一场持久战:哪怕是一个小系统,也要养成良好的安全意识;
  5. 善用社区工具:Spring Security 社区强大,很多问题已经有最佳实践,没必要重复造轮子;
  6. 保持学习和思考:OAuth2、JWT、SAML2 这些都在演进,保持对新技术的好奇心很重要。

最后的话:写给正在努力的你

说实话,我在最初学习 Spring Security 的时候也非常懵圈。文档又长又枯燥,网上搜出来的教程又太理想化,根本不知道在生产环境怎么用。

所以我写下这篇文章的目的,不仅是教你“怎么搭”,更是想告诉你,“为什么这么搭”,以及“出了问题怎么办”。

希望这篇文章对你有所帮助。如果文中有什么地方描述得还不够清楚,欢迎留言交流~我也很乐意继续深入探讨这个问题。

祝你早日成为真正的「安全后端工程师」!💪


参考链接:

评论 0

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