从零开始构建安全认证系统:我在Spring Security实践中的那些事儿

~马智
2025-06-19 12:43
阅读 304

开篇 | 起点,往往比想象中更曲折

开篇 | 起点,往往比想象中更曲折

大概在一年前,我所在的团队负责开发一个企业内部管理系统,用户权限相对复杂,安全要求也比较高。虽然我们过去做过不少后台系统,但这次的需求明确提到了要“具备完整的身份验证与授权体系,并支持多角色分级管理”。于是,Spring Security这个曾经只停留在知识储备层面的技术框架,成了我们的首选。

刚开始信心满满,认为不过就是搭个框架、配几个过滤器的事儿。可真正深入后,才意识到事情并没有那么简单——配置项五花八门,文档版本混乱,自定义逻辑难搞,还有各种各样的“陷阱”等着你去踩。这篇文章就想结合那次项目的实战经历,聊聊我是怎么一步步用 Spring Security 快速搭建起一个基础而实用的认证系统,以及中间踩过的坑和总结的经验。


问题描述 | 不是所有需求都能“默认解决”

问题描述 | 不是所有需求都能“默认解决”

项目初期的需求其实不算太复杂:

  1. 实现基于用户名/密码的登录认证
  2. 支持不同的用户角色(管理员、运营、普通员工)
  3. 不同角色访问接口需要不同权限控制
  4. 登录后的Token机制需长期可用且便于扩展

当时我们的技术栈是 Spring Boot + MyBatis,数据库使用 MySQL,前端是 Vue 框架,整体前后端分离设计。这本来看似很标准的组合,但在实际整合过程中却遇到了一系列挑战:

  • 默认的 formLogin 配置方式不符合前后端分离的交互模式,需要手动处理 JSON 提交和响应。
  • 自定义 UserDetailsService 实现时遇到数据库查询性能瓶颈。
  • 多层级权限配置容易出错,尤其在 URL 权限匹配规则上经常“放水”。
  • Token 管理没有现成方案,得自己整合 JWT 或 Redis 做状态维护。

这些问题看似细碎,但堆叠在一起就会严重影响交付节奏。尤其是面对上线时间压缩的背景,如何快速高效地搭建出安全认证的核心功能,成了关键。


解决思路 | 以实用为导向的设计哲学

解决思路 | 以实用为导向的设计哲学

我决定采用渐进式构建 + 核心抽象封装的方式推进:

第一步:明确安全边界与职责

我们先梳理了一个“安全控制矩阵”,明确了哪些接口需要认证,哪些接口需要特定角色才能访问,从而为后续配置URL权限提供了清晰依据。

接口路径 认证要求 角色限制
/api/user/list 管理员
/api/order/detail 运营/管理员
/api/public/info 公开

第二步:基于 Spring Security 构建核心骨架

核心目标是实现以下功能:

  • 用户登录认证(支持账号密码)
  • 多角色鉴权
  • 使用 JWT 实现无状态会话管理(适合前后端分离)
  • 安全上下文持久化与共享

为了提高后期灵活性,我还做了一些初步的抽象封装,比如将用户信息加载服务从 UserDetailsService 抽出来统一管理,避免业务与安全耦合过重。


代码实战 | 关键配置与核心实现

1. 引入依赖

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

<!-- JWT工具类 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>

2. WebSecurityConfig 类示例

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private final UserService userDetailsService;
    private final JwtRequestFilter jwtRequestFilter;

    public WebSecurityConfig(UserService userDetailsService, JwtRequestFilter jwtRequestFilter) {
        this.userDetailsService = userDetailsService;
        this.jwtRequestFilter = jwtRequestFilter;
    }

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

    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
                .antMatchers("/api/auth/**").permitAll()
                .antMatchers("/api/user/list").hasAuthority("ROLE_ADMIN")
                .anyEndpoint().authenticated();

        return http.build();
    }
}

3. JWT 过滤器实现片段

public class JwtRequestFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;

    public JwtRequestFilter(UserDetailsService userDetailsService, JwtUtil jwtUtil) {
        this.userDetailsService = userDetailsService;
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String tokenHeader = request.getHeader("Authorization");
        String username = null;
        String token = null;

        if (tokenHeader != null && tokenHeader.startsWith("Bearer ")) {
            token = tokenHeader.substring(7);
            try {
                username = jwtUtil.getUsernameFromToken(token);
            } catch (JwtException e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT");
                return;
            }
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);
    }
}

4. 数据库模型设计参考

CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    enabled BOOLEAN DEFAULT TRUE
);

CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
);

CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    FOREIGN KEY(user_id) REFERENCES sys_user(id),
    FOREIGN KEY(role_id) REFERENCES sys_role(id)
);

踩坑经验 | 那些年我们一起趟过的“雷”

坑一:JWT 过期策略没做好,导致用户频繁掉线

一开始我们直接使用 JWT 的 expireTime 设置了固定时间,结果发现有的用户在线期间 JWT 过期了,又没有自动续签机制,只能重新登录。后来加上了一个简单的刷新机制,在每次请求成功后,如果 JWT 即将到期,就在 header 中返回新的 token。

坑二:忽略跨域设置导致 OPTIONS 请求失败

因为是前后端分离架构,初期忽略了对 CORS 的配置,Spring Security 默认会拦截所有非认证的 OPTIONS 请求。后来我们在 WebSecurityConfig 中加了如下配置:

http.cors().configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues());

同时前端设置好对应的 headers 和 origin 白名单。

坑三:权限表达式写法有误,导致“该拦的没拦住”

我们最初直接用了 .hasRole("ADMIN"),但由于 Spring Security 对 ROLE_ 前缀有内置识别机制,最终修改为 .hasAuthority("ROLE_ADMIN") 才生效。


效果总结 | 变革不止于技术本身

这套安全模块上线后,给整个团队带来了几个明显的变化:

  1. 安全性提升明显:通过完善的认证流程和角色隔离机制,防止了越权访问等常见漏洞;
  2. 开发效率提高:后续新接口接入只要按照预设的注解或配置添加权限即可;
  3. 运维更加可控:日志中可以清晰看到每个请求的认证情况,审计追踪变得容易;
  4. 扩展性良好:比如后期我们要加入 OAuth 登录时,只需要扩展 AuthenticationProvider 即可完成对接。

最重要的是,这套系统让我们在后续多个项目中都实现了“开箱即用”的权限结构,大大减少了重复开发成本。


经验分享 | 给刚入门同学的一些忠告

  1. 不要迷信“全自动配置”
    Spring Security 功能强大,但很多默认行为并不适用于真实场景,动手改配置是常态。建议从头开始构建流程图,再逐步添加模块。

  2. 尽早考虑 Token 状态管理
    如果只是简单系统可以用无状态 JWT;如果有高并发、单点登录、强制下线等需求,建议引入 Redis + JWT 结合方案,方便集中管理。

  3. 权限粒度要分层设计
    URL级控制是起点,方法级控制(如 AOP)是进阶,最后才是数据级的动态过滤。

  4. 别忽视异常处理机制
    认证失败、未授权访问这些错误一定要统一格式返回,否则前端很难做全局处理。

  5. 测试先行
    安全问题不容易在运行态发现问题,建议搭配单元测试,模拟各种角色访问,及时排查疏漏。


写在最后 | 安全是底线,也是底气

现在回过头看,那段时间的确挺难,但收获也非常大。从最开始看着 Spring Security 官方文档发懵,到能熟练地调整过滤器链、编写自定义鉴权逻辑、甚至和运维一起优化安全日志记录……每一个细节的背后,都是不断试错、思考和重构的结果。

对于开发者来说,安全不是一项附加功能,而是系统的根基之一。而 Spring Security 就像是一把双刃剑,用得好可以事半功倍,用不好也可能埋下隐患。希望这篇分享能让你少走一点弯路,早点搭出一套属于自己的安全防线。

如果你正在学习或准备落地 Spring Security,不妨试试从最小可行性认证入手,边做边学,一定会更快上手。毕竟,没有什么比亲手跑起来更有说服力了 😊


作者简介:某一线互联网公司后端架构师,多年分布式系统及微服务开发经验,主导过多套平台级系统建设,对 Java 生态、系统安全性设计有持续探索和落地实践。欢迎交流探讨,共同成长。

评论 0

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