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

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

上周五晚上十点半,我坐在工位上盯着屏幕上那条 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 只存 userIdexp,权限信息走缓存
  • 懒加载:首次访问受保护接口时才查权限,后续走缓存
// 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

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