V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
HikariLan
V2EX  ›  分享创造

实战!用 Spring Gateway 配合 Sa-Token 实现微服务无感鉴权

  •  
  •   HikariLan ·
    shaokeyibb · 267 天前 · 2317 次点击
    这是一个创建于 267 天前的主题,其中的信息可能已经有所发展或是发生改变。

    实战!用 Spring Gateway 配合 Sa-Token 实现微服务无感鉴权

    前言

    众所周知,Spring Cloud Gateway 是一个基于 Spring WebFlux 技术构建的高性能微服务网关,通过 Spring Cloud Gateway ,我们可以实现对微服务的负载均衡,服务治理等功能;Sa-Token 则是一款轻量级的 Java 权限认证框架,通过 Sa-Token 我们可以非常简便的实现服务的鉴权功能。

    在业务实践中,我们可以直接在网关对需要鉴权的路由进行访问鉴权,阻止未登录或无权限用户访问指定 API/页面。Sa-Token 的文档也描述了这种网关统一鉴权的解决方案,但这依然不能解决一些问题:

    1. 下游微服务依然需要依赖 Sa-Token (或者通过中间件)获取用户信息,没有做到无感鉴权;
    2. 由于上述原因,导致下游微服务与 Sa-Token 耦合度过高,并且由于需要重复获取一次用户信息(在网关已经获取了一次),造成了额外的数据访问。

    因此,本文提供了一种无感鉴权的方案,通过直接向下游微服务请求注入用户 ID 的方式,做到了无感鉴权,使鉴权服务对下游微服务保持透明。

    本文全程使用 Java 17 + Spring Boot 3 作为示例,对于传统 Java 8 + Spring Boot 2 项目,除部分依赖需使用 Spring Boot 2 适配版本,整体代码变化不大。

    无感鉴权的实现

    引入依赖

    首先,创建一个标准 Spring Boot 3 项目,并引入 Spring Cloud Gateway 和 Sa-Token 的相关依赖:

    plugins {
      // 引入 Java 插件
      java
      // 引入 Spring Boot 插件
      id("org.springframework.boot") version "3.1.2"
      // 引入 Spring 依赖管理插件
      id("io.spring.dependency-management") version "1.1.2"
    }
    ​
    java {
        // 设置 Java 源代码版本为 Java 17
        sourceCompatibility = JavaVersion.VERSION_17
    }
    ​
    repositories {
      // 引入 Maven 中央库
      mavenCentral()
    }
    ​
    // 设置 Spring Cloud 版本
    extra["springCloudVersion"] = "2022.0.4"
    ​
    dependencies {
        // 引入 Spring Cloud Gateway 的 Spring Boot starter 依赖
        implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    ​
        // 重要:引入 Sa-Token 的 Spring Boot 3 Webflux 依赖(而不是 Spring Boot 2 Webflux )
        implementation("cn.dev33:sa-token-reactor-spring-boot3-starter:1.35.0.RC")
        // 引入 Sa-Token 的 redis 支持依赖
        implementation("cn.dev33:sa-token-redis:1.35.0.RC")
        // 引入连接池
        implementation("org.apache.commons:commons-pool2")
    }
    ​
    dependencyManagement {
      // 导入 Maven Bom
      imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
      }
    }
    

    创建路由

    创建 RoutesConfiguration 类,并将其注册为 Configuration 类,创建路由逻辑,例如:

    import org.springframework.cloud.gateway.route.RouteLocator;
    import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    ​
    @Configuration
    public class RoutesConfiguration {
    ​
        @Bean
        public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
            return builder.routes()
                    .route("service-user", r -> r.path("/api/users/**")
                            .uri("lb://service-user"))
                    .route("service-auth", r -> r.path("/api/authorization/**")
                            .uri("lb://service-auth"))
                    .route("frontend", r -> r.path("/**")
                            .uri(frontendUrl))
                    .build();
        }
    ​
    }
    

    实现鉴权接口

    创建 StpInterfaceImpl 类,实现 StpInterface 类并将其注册为 Component:

    import cn.dev33.satoken.stp.StpInterface;
    import org.springframework.stereotype.Component;
    ​
    import java.util.List;
    ​
    @Component
    public class StpInterfaceImpl implements StpInterface {
    ​
        @Override
        public List<String> getPermissionList(Object loginId, String loginType) {
            return List.of(); // TODO: 返回此 loginId 拥有的权限列表
        }
    ​
        @Override
        public List<String> getRoleList(Object loginId, String loginType) {
            return List.of(); // TODO: 返回此 loginId 拥有的角色列表
        }
    ​
    }
    

    注册全局过滤器

    创建 SaTokenConfigure 类,并将其注册为 Configuration 类,添加路由鉴权逻辑,例如:

    @Configuration
    public class SaTokenConfigure {
        // 注册 Sa-Token 全局过滤器 
        @Bean
        public SaReactorFilter getSaReactorFilter() {
            return new SaReactorFilter()
                // 拦截地址 
                .addInclude("/**")    /* 拦截全部 path */
                // 开放地址 
                .addExclude("/favicon.ico")
                // 鉴权方法:每次访问进入 
                .setAuth(obj -> {
                    // 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录 
                    SaRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());
                    
                    // 权限认证 -- 不同模块, 校验不同权限 
                    SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
                    SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
                    SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
                    SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
                    
                    // 更多匹配 ...  */
                })
                // 异常处理方法:每次 setAuth 函数出现异常时进入 
                .setError(e -> {
                    return SaResult.error(e.getMessage());
                })
                ;
        }
    }
    

    添加过滤器,实现无感鉴权

    为 Webflux 请求添加过滤器,从 Sa-Token 获取用户登录 ID ,并将其添加到请求头中:

    import cn.dev33.satoken.stp.StpUtil;
    import io.hikarilan.nerabbs.common.BizConstants;
    import org.springframework.cloud.gateway.filter.GatewayFilterChain;
    import org.springframework.cloud.gateway.filter.GlobalFilter;
    import org.springframework.http.server.reactive.ServerHttpRequest;
    import org.springframework.stereotype.Component;
    import org.springframework.web.server.ServerWebExchange;
    import reactor.core.publisher.Mono;
    ​
    @Component
    public class AuthorizeFilter implements GlobalFilter {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            ServerHttpRequest newRequest = exchange
                    .getRequest()
                    .mutate()
                    .header("X-User-ID", StpUtil.getLoginId(-1L).toString())
                    .build();
            ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
    ​
            return chain.filter(newExchange);
        }
    }
    ​
    

    以上代码拦截了 HTTP 请求,获取了 Sa-Token 存储的当前请求用户登录 ID ,并将其注入到 X-User-ID 请求头中。如果用户未登录则返回 -1

    下游微服务获取用户 ID

    最后,任何下游微服务只需要获取 X-User-ID 请求头便可得知用户登录 ID (或者未登录,得到 -1

    @GetMapping
    @ResponseBody
    public UserBasicInfoVo getUserBasicInfoFromHeader(@RequestHeader("X-User-ID") long userID) {
        if (userID == -1)
            throw new UnauthorizedException();
    ​
        return userInfoService.getUserBasicInfoByID(userID);
    }
    

    如此一来,我们便做到了无感鉴权以及对 Sa-Token 鉴权服务的解耦。

    最后

    最后发点自己的小牢骚,我曾经是很看好 Sa-Token 这款框架的,因为他用起来的心智负担确实比 Spring Security 低很多,很容易就能搭建一套鉴权系统出来。但是前几天发生的一个事情却让我近乎想要拉黑这个软件,乃至不再想写这篇文章。而这一切的罪魁祸首就是 Sa-Token 最近对其文档的更新:

    update doc · dromara/Sa-Token@2ef8a82 (github.com)

    在本次修改中,Sa-Token 强制要求用户必须前往其 Gitee 仓库对该软件 star ,且授权 Sa-Token 的远程服务器获取 Gitee 的 OAuth 权限以检测用户是否真正点击了 star 。

    我认为这种行为无异于是耍流氓,是赤裸裸的欺诈,对国内开源环境的又一重挫。

    希望 Sa-Token 能重新考虑该功能的设立,还国内一个良好的开源环境。

    12 条回复    2024-04-07 18:08:41 +08:00
    ljsh093
        1
    ljsh093  
       267 天前   ❤️ 2
    update doc · dromara/Sa-Token@2ef8a82 (github.com)

    在本次修改中,Sa-Token 强制要求用户必须前往其 Gitee 仓库对该软件 star ,且授权 Sa-Token 的远程服务器获取 Gitee 的 OAuth 权限以检测用户是否真正点击了 star 。

    这还用个啥?
    qinfengge
        2
    qinfengge  
       266 天前
    唉,国内开源真是死路一条吗
    papalong
        3
    papalong  
       266 天前
    Desdemor
        4
    Desdemor  
       266 天前
    @papalong 第一次见这么离谱的
    wanniwa
        5
    wanniwa  
       266 天前
    Sa-Token 确实这样很败好感,本来朋友推荐的,我用着也蛮好,昨天看文档的时候发现强制要求收藏
    zagfai
        6
    zagfai  
       266 天前
    偷笑一下 Java 系 出来的人才:)
    lifespy
        7
    lifespy  
       266 天前
    你的这个思路,我们前年开始就是这么勇的,实践确实很不错。
    感谢分享
    nekoneko
        8
    nekoneko  
       266 天前
    Lip's mama opened the door for Lip.
    FormatToday
        9
    FormatToday  
       265 天前
    人家还会删评论哦,我在那个 commit 下写了句”成功劝退至少一个用户“,然后被删了。
    邮箱提醒还在呢
    ![]( )
    russ44
        10
    russ44  
       265 天前
    从强制关注公众号到强制要 Star
    s1461a
        11
    s1461a  
       159 天前
    屏蔽掉那个 http 请求就行
    allgy
        12
    allgy  
       24 天前
    真特么离谱
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1792 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 16:13 · PVG 00:13 · LAX 09:13 · JFK 12:13
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.