Spring Security 基础:快速搭建安全认证系统,一个北漂程序员的实战记录

Web开发者
2025-12-17 03:11
阅读 3489

上周五晚上十点半,我坐在工位上盯着屏幕上那条 403 Forbidden 的报错,心里直冒火。房贷刚还完这个月的,老板又催着上线新功能——用户权限模块得在下周一前搞定。偏偏产品经理昨天还跑来说:“能不能支持微信扫码登录?顺便加个角色分级?” 我差点把键盘砸了。

作为一个早起型选手(每天8点准时坐到工位,不然通勤地铁挤到怀疑人生),我已经连续三天边刷 LeetCode 边改权限逻辑了。跳槽面试官最爱问“你用过哪些安全框架”,结果我连 Spring Security 都没正经搭过…… 被逼无奈,只能硬着头皮啃文档、扒源码,好歹赶在周末前把基础认证搞定了。今天这篇就当是复盘笔记,也给同样被权限问题折磨的兄弟们省点时间。


起因:不是我想学,是需求追着我跑

事情要从公司最近做的内部管理系统说起。我们是个小厂,团队不到20人,后端就仨Java开发(包括我)。之前为了赶双11大促,权限这块直接用最原始的方式:每个接口手动 check userId + role。代码丑得像泡面桶堆成的塔,测试一测就崩,运维天天在群里@我:“又有人越权访问了!”

上周产品提了个需求:接入第三方登录 + 多角色细粒度控制。我第一反应是“这不就是 Spring Security 的主场吗?” 但说实话,以前光听说过,没真用过。看网上教程一堆 WebSecurityConfigurerAdapter(现在都废弃了!),配置复杂得像在解魔方。更气人的是,隔壁组用 Go 写的微服务,鉴权直接用中间件几行搞定,而我还在纠结 AuthenticationManager 到底怎么注入……

真实场景吐槽:Go 组的同事甚至用爬虫抓我们系统的 API 文档,说“你们 Java 的权限模型太黑盒了,不如我们 Go 的清晰”。我当场反呛:“你们那是没遇到复杂 RBAC!” —— 其实心里慌得一批。


开干:从零搭建,别怕踩坑

第一步:别信过时教程!

Spring Security 5.7+ 已经移除了 WebSecurityConfigurerAdapter,但 80% 的中文博客还在教这个。我一开始照着抄,结果启动直接报错:

Consider defining a bean of type 'org.springframework.security.authentication.AuthenticationManager'

查了一晚上 Stack Overflow 才知道,现在要用 SecurityFilterChain 的方式配置。血泪教训:永远看官方最新文档,别信 CSDN 三年前的帖子。

第二步:建表设计,别偷懒

权限系统的核心是数据模型。我参考了 RBAC(Role-Based Access Control)标准,建了四张表:

表名 说明
sys_user 用户基本信息(含密码哈希)
sys_role 角色表(如 ADMIN, USER)
sys_permission 权限点(如 user:read, order:create)
user_role, role_permission 两张关联表

开发心得:千万别把权限字符串直接存在用户表里!后期改权限要哭死。我们之前就这么干过,改个权限得全量 update 用户表,DBA 直接找我喝茶。

密码存储必须用 BCryptPasswordEncoder,别学某些老项目用 MD5(对,说的就是我司三年前的祖传代码)。

第三步:核心配置,一行都不能少

新建 SecurityConfig.java,这才是现代 Spring Security 的正确打开方式:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 前后端分离可关掉,但生产环境慎用!
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

关键点解释:

  • STATELESS:因为是前后端分离,用 JWT 无状态认证
  • addFilterBefore:自定义 JWT 过滤器放在默认用户名密码过滤器之前
  • permitAll():登录和公开接口放行

第四步:自定义 JWT 认证过滤器

这才是重头戏。我写了个 JwtAuthenticationFilter,核心逻辑:

  1. 从 Header 取 Authorization: Bearer <token>
  2. 解析 token,验证签名和过期时间
  3. 如果有效,构造 UsernamePasswordAuthenticationToken 放入 SecurityContext
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        
        String token = extractToken(request);
        if (token != null && jwtUtil.validateToken(token)) {
            String username = jwtUtil.getUsernameFromToken(token);
            
            // 从数据库加载用户详情(含权限)
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken auth = 
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }
}

踩坑记录:最开始我忘了 setDetails(),导致某些安全上下文信息丢失,单元测试全挂。后来翻源码才发现 WebAuthenticationDetails 会存客户端 IP 和 session ID,有些审计日志依赖这个。


性能与生产环境考虑

数据库查询优化

每次请求都要查用户+角色+权限?那 DB 不就炸了?我的方案:

  • 缓存用户权限:登录成功后,把用户权限列表塞进 Redis,key 为 auth:permissions:{userId}
  • JWT 携带最小信息:token payload 只存 userId 和 exp,权限信息走缓存
  • 懒加载:首次访问受保护接口时才查权限,后续走缓存
// UserServiceImpl.java
public List<String> getPermissions(Long userId) {
    String cacheKey = "auth:permissions:" + userId;
    List<String> perms = redisTemplate.opsForList().range(cacheKey, 0, -1);
    if (perms == null || perms.isEmpty()) {
        perms = permissionMapper.selectByUserId(userId);
        redisTemplate.opsForList().leftPushAll(cacheKey, perms);
        redisTemplate.expire(cacheKey, 30, TimeUnit.MINUTES); // 缓存30分钟
    }
    return perms;
}

接口设计规范

和前端约定好:

  • 登录成功返回 { token: "xxx", userInfo: { id, name, roles } }
  • 所有受保护接口,前端必须在 Header 带 Authorization: Bearer <token>
  • 权限不足时,后端统一返回 403 { code: 40301, msg: "权限不足" }

这样前端才能做统一拦截处理,不用每个页面单独判断。


和 Go 爬虫组的“技术交流”

说回开头那个 Go 组的同事。其实他们最近也在做权限系统,但他们用 Go 写了个轻量级中间件:

func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !validateToken(token) {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next(w, r)
    }
}

确实简洁!但当我问他:“如果要实现‘部门经理只能看本部门数据’这种动态权限呢?” 他愣住了。Spring Security 的 @PreAuthorize("hasPermission(#orderId, 'ORDER', 'READ')") 这种表达式级别的控制,Go 中间件就得自己造轮子。

开发心得:框架不是银弹,但复杂业务场景下,Spring Security 的扩展性真的香。当然,如果是简单 API 网关鉴权,Go 确实更快更轻。


最后:跳槽、房贷与技术成长

折腾完这套权限系统,我不仅按时交付了需求(老板终于没在周会上点名批评),还顺手整理了一份 Spring Security 面试八股文。最近面试了几家大厂,聊到安全模块时底气足多了——毕竟亲手踩过坑的人,和只会背概念的人,聊起来完全不是一个 level。

虽然每天早起挤地铁、晚上回家还得刷题的日子很苦,但看着账户里慢慢增长的技术积累,感觉房贷的压力也没那么窒息了。程序员这行,说到底还是靠解决问题的能力吃饭。Spring Security 看似复杂,拆开来看也就是:认证(你是谁) + 授权(你能干啥) + 安全上下文(记住你是谁)。

下次再有人问我“Java 权限怎么搞”,我就能甩出这篇实战记录,而不是尴尬地说“我用拦截器手写的”。


附:常见问题速查表

问题现象 可能原因 解决方案
登录成功但访问接口 403 权限未加载到 SecurityContext 检查 UserDetails.getAuthorities() 是否返回非空
JWT 过期后仍能访问 过滤器未校验 token 有效性 在 JwtAuthenticationFilter 中调用 jwtUtil.validateToken()
自定义 UserDetailsService 不生效 未注入到 AuthenticationProvider 确保 DaoAuthenticationProvider 设置了 userDetailsService
跨域请求被拦截 CSRF 未关闭或 CORS 配置缺失 添加 .cors().and().csrf().disable()

友情提示:生产环境千万别关 CSRF!除非你确定是纯 API 服务且有其他防护措施。


写完这篇已经是凌晨一点,明天还要早起改 Bug。但至少,今晚能睡个踏实觉了——毕竟,权限系统没崩,房贷还能继续还。

评论 0

最热最新
暂无评论
Web开发者Lv.1
0
影响力
0
文章
0
粉丝