从零搭建安全认证系统:我在项目中踩过的那些“坑”和学到的经验

Prompt造梦师
2025-06-29 23:59
阅读 744

背景:一个看似简单的权限需求,让我彻夜难眠

背景:一个看似简单的权限需求,让我彻夜难眠

去年年初,我所在的互联网公司要上线一个内部管理系统。作为后端负责人,我接到的需求是:“这个系统需要有用户登录、角色权限管理,还有对外的接口调用权限控制。”听起来似乎是个很常规的开发任务。

但当我真正开始动手设计权限模块时,问题接踵而至:

  • 如何统一处理所有请求的权限判断?
  • 用户登录如何做 Token 认证?JWT 还是 Session?
  • 不同角色能访问不同接口,这个怎么配置才灵活?
  • 多租户环境下权限该怎么设计?
  • 权限数据变更后,如何不重启服务生效?

我意识到,这不再是一个简单的拦截器+数据库鉴权的方案可以搞定的事情了。我们需要一个更系统、可扩展、且性能可控的安全机制。于是,我决定尝试使用 Spring Security 来构建整个安全认证体系。

这篇文章,我想跟你分享我当时是如何一步步搭建起这个系统的,以及过程中踩过的一些坑和一些实战经验。


第一步:为什么选择 Spring Security?

第一步:为什么选择 Spring Security?

在 Java 后端生态中,Spring Security 是目前最主流的安全框架。虽然一开始学习曲线略陡,但一旦掌握之后它的灵活性和稳定性远胜于自己手写权限逻辑。

当时我们团队的选型会议中也讨论过其他方案,比如 Apache Shiro。但考虑到我们的系统将来可能会接入 OAuth2、支持多租户、甚至对接企业微信/钉钉,最终我们选择了 Spring Security——它对这些场景的支持更好,社区活跃度也很高。


架构设计:安全层应该放在哪里?

架构设计:安全层应该放在哪里?

我们在系统架构上做了如下分层设计:

浏览器/移动端 → Nginx(负载) → Gateway(网关鉴权) → Spring Boot 微服务

这里的关键点在于:鉴权不是只在业务服务里做,而是应该前置到 Gateway 层

这样做的好处很明显:

  • 减少每个微服务重复鉴权的负担;
  • 可以在网关实现全局权限控制;
  • 接口级别的黑白名单可以快速生效;
  • 更容易支持 JWT、OAuth 等多种认证方式。

不过,这也带来了一个问题:Spring Security 默认是在 Web 容器中初始化的,而 Gateway 是独立的服务。所以我们必须把 Spring Security 的 FilterChain 和 Authentication 机制迁移到 Gateway 中(后面会细讲)。


登录流程设计:Token 认证 vs Session 认证

我们在调研阶段对两种方案做了对比:

方案 优点 缺点
Session 认证 安全性好,服务端可控性强 集群部署下需 Session 共享
JWT Token 认证 无状态,便于水平扩展 Token 注销困难、安全性依赖加密算法

结合我们系统未来可能的扩展性考虑(比如多集群、跨地域部署),最终我们选择了 基于 JWT 的 Token 认证方案

整个登录流程大致如下:

  1. 用户提交用户名密码到 /login 接口;
  2. 校验通过后,生成带有 userIdexp 的 JWT;
  3. 将 Token 返回给客户端;
  4. 客户端后续请求带上该 Token;
  5. 拦截器解析 Token 获取当前用户信息,并完成权限校验;
  6. 若合法则进入 Controller;否则返回 401。

在这个过程中,我遇到的第一个问题是:如何在 Spring Security 中集成 JWT 认证机制?


技术实现:自定义 Token 过滤器 + 动态权限加载

1. 创建 JWT 工具类

我们使用的是 Java-JWT 库来处理 Token 的签发与验证:

public String generateToken(Authentication authentication) {
    UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
    
    return Jwts.builder()
        .setSubject(userPrincipal.getUsername())
        .claim("authorities", userPrincipal.getAuthorities())
        .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
        .signWith(SignatureAlgorithm.HS512, SECRET)
        .compact();
}

这里的 Authentication 是 Spring Security 提供的认证对象,后续我们会手动创建并交由 Spring Security 管理。

2. 自定义 JWTFilter 实现 Token 解析

为了将 Token 解析后的结果注入到 Spring Security 的上下文中,我们需要自定义一个过滤器:

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response,
                                    FilterChain filterChain)
        throws ServletException, IOException {
        
        String token = getTokenFromRequest(request);
        
        if (token != null && validateToken(token)) {
            Authentication auth = getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        filterChain.doFilter(request, response);
    }

    private Authentication getAuthentication(String token) {
        // 解析 Token 并构造 UsernamePasswordAuthenticationToken
    }
}

这个过滤器的作用就是每次请求都检查是否有 Token,并将其转换为 Spring Security 能识别的 Authentication 对象。

3. 加载用户信息和权限

接下来,我们需要在用户登录成功后构建一个完整的 Authentication 对象。这里涉及到两个关键类:

  • UserDetailsService:根据用户名获取用户信息;
  • GrantedAuthority:封装用户的权限列表。

我们的实现方式是:

@Component
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userService.findByUsername(username);
        
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        
        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            getAuthority(user.getRoles())
        );
    }

    private Collection<? extends GrantedAuthority> getAuthority(Collection<RoleEntity> roles) {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (RoleEntity role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return authorities;
    }
}

然后,在登录接口中:

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
    );
    
    SecurityContextHolder.getContext().setAuthentication(authentication);
    
    String token = jwtUtils.generateToken(authentication);
    
    return ResponseEntity.ok().body(new JwtResponse(token));
}

这样,我们就能让 Spring Security 在后续的权限判断中正常使用这套认证机制了。


动态权限控制:@PreAuthorize 和 URL 匹配的双保险

为了实现接口级别的权限控制,我们主要用了两种方式:

1. 方法级别的注解:@PreAuthorize

这是 Spring Security 的预授权机制,可以在方法执行前进行权限判断:

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

这种写法非常直观,适合权限粒度较粗的场景。

但也有缺点:修改权限需要改代码重新发布。因此我们并没有大规模使用,主要用于管理员后台等固定角色接口。

2. URL级别的权限控制:动态加载 + FilterSecurityInterceptor

我们采用的方式是在运行时从数据库加载权限规则,然后动态注入到 Spring Security 的 FilterChain 中。

这部分涉及较为复杂的配置,核心思路是:

  • 在启动或权限变更时加载权限表;
  • 构建 URL 到权限名称的映射;
  • 实现 SecurityMetadataSource 接口返回 URL 所需的权限;
  • 使用 AccessDecisionManager 做权限决策。

这种方式的优点是权限可以动态更新,无需重启服务。当然代价是复杂度高了一点。

举个例子,我们有一张权限表结构如下:

id url_pattern required_role
1 /api/user/** ROLE_USER
2 /api/admin/** ROLE_ADMIN

每当权限发生变化,我们就触发一次 updateSecurityConfig() 方法,刷新 Spring Security 内部的匹配规则。


生产环境中的运维问题和应对策略

1. Token 有效期太短,用户频繁登录怎么办?

微服务架构示意图-2

初期我们设置的 Token 有效时间是 1 小时,结果测试部门反馈“经常要重新登录”,体验很差。

解决方法是引入 Refresh Token

  • Token 用于短期请求;
  • Refresh Token 有效期长,用于换取新的 Token;
  • Refresh Token 存储在 Redis 中,失效后需重新登录。
@PostMapping("/refresh-token")
public ResponseEntity<?> refreshToken(HttpServletRequest request) {
    String token = extractToken(request);
    String refreshedToken = jwtUtil.refreshToken(token);
    return ResponseEntity.ok(refreshedToken);
}

API接口文档-1

这样既能保证安全,又不至于让用户频繁输入账号密码。

2. 多租户下的权限冲突问题

我们后来在系统中接入了“多租户”的需求。这就带来了新的挑战:同一权限名在不同租户下含义不同

例如:

TENANT_A 有一个名为 ROLE_ADMIN 的角色;
TENANT_B 也有一个名为 ROLE_ADMIN 的角色。

如果不加区分,Spring Security 会误认为它们是同一个权限!

我们的解决方案是:在权限名称前加上租户标识,如:

ROLE_TENANT_A_ADMIN

同时,在登录后记录当前 TenantId,并在权限校验时一并带入。

3. 权限缓存导致权限更新不及时

有时候,运维人员更新了用户的角色,但前端仍然显示没有权限。

这是因为 Spring Security 默认不会自动刷新用户的权限信息。

我们的做法是:在用户权限更新后,主动清理掉对应用户的 SecurityContext,并让他下次请求时重新拉取最新的权限。


项目收益:不只是功能,更是架构上的提升

这套认证系统上线后,整体效果非常不错:

  • 登录流程稳定,未出现大规模 Token 失效问题;
  • 接口权限控制灵活,可随时调整;
  • 支持多租户、API级别权限,满足了各种扩展需求;
  • 减少了大量手工权限校验代码,提升了维护效率。

更重要的是,这套权限模型为后续接入 OAuth2、SSO、企业微信登录等高级功能打下了坚实基础。


给你的建议:Spring Security 最实用的几个Tips

作为一个踩过很多坑的开发者,我想给你分享一些亲身体验:

  1. 不要试图绕开 Spring Security 的机制,尤其是初学者,很容易陷入“自己搞一套”的陷阱。其实它的默认行为都是经过长期打磨的,别轻易重写。

  2. 优先使用方法注解做权限控制,除非你真的需要动态加载权限。大多数场景已经够用了。

  3. JWT 的 Secret 必须保密,千万不要硬编码在代码里!建议使用 K8s Secret 或者 Vault 来集中管理敏感信息。

  4. 权限配置最好可视化,比如做一个后台界面专门管理 URL 对应的角色权限,方便产品同学操作。

  5. 监控 Token 的生命周期,包括生成、刷新、失效、吊销。可以用 Prometheus + Grafana 做指标看板。

  6. 测试一定要覆盖权限边界情况,比如一个角色同时拥有多个权限,或者某接口没有任何权限的情况。

  7. 不要迷信 Token 认证就是万能的,有些场景 Session 依然适用,尤其在内网系统中。


结语:安全不是一时的工程,而是一种持续的保障

回想起当初刚接手这个项目的忐忑,到现在看着系统稳定运行,我觉得最大的收获不仅是掌握了 Spring Security 的使用技巧,更重要的是对“系统安全”这件事有了更深的理解。

它不仅仅是防止别人非法登录,更是对整个系统的一种责任和敬畏。

如果你正在搭建自己的认证系统,不妨试试 Spring Security。也许刚开始会觉得复杂,但一旦理解它的运作机制,你会发现它就像一位老练的守门人,默默守护着你的应用。

愿你在技术之路上越走越远,写出更加健壮、安全、优雅的系统!

评论 0

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