V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Authing
V2EX  ›  API

插件开发版| Authing 结合 APISIX 实现统一可配置 API 权限网关

  •  
  •   Authing · 2023-03-24 17:22:46 +08:00 · 1510 次点击
    这是一个创建于 612 天前的主题,其中的信息可能已经有所发展或是发生改变。

    当开发者在构建网站、移动设备或物联网应用程序时,API 网关作为微服务架构中不可或缺的控制组件,是流量的核心进出口。通过有效的权限管控,可以实现认证授权、监控分析等功能,提高 API 的安全性、可用性、拓展性以及优化 API 性能。之前我们演示了通过 Authing 权限管理 + APISIX 实现 API 的访问控制效果,本文将教你如何实现上述能力的具体实践方法

    01 关于 Authing

    Authing 是国内首款以开发者为中心的全场景身份云产品,集成了所有主流身份认证协议,为企业和开发者提供完善安全的用户认证和访问管理服务。以「 API First 」作为产品基石,把身份领域所有常用功能都进行了模块化的封装,通过全场景编程语言 SDK 将所有能力 API 化提供给开发者。同时,用户可以灵活的使用 Authing 开放的 RESTful APIs 进行功能拓展,满足不同企业不同业务场景下的身份和权限管理需求

    02 关于 APISIX

    Apache APISIX 是一个动态、实时、高性能的 API 网关,提供负载均衡、动态上游、灰度发布、服务熔断、身份认证、可观测性等丰富的流量管理功能。Apache APISIX 不仅支持插件动态变更和热插拔,而且拥有众多实用的插件。Apache APISIX 的 OpenID Connect 插件支持 OpenID Connect 协议,用户可以使用该插件让 Apache APISIX 对接 Authing 服务,作为集中式认证网关部署于企业中。

    03 业务目标

    通过 Authing 权限管理 + APISIX 实现 API 的访问控制

    04 如何实现

    本文所涉及到的代码已经上传到 Github

    Python 插件https://github.com/fehu-asia/authing-apisix-python-agent

    Java Adapter: https://github.com/fehu-asia/authing-apisix-java-adapter

    Java 插件https://github.com/fehu-asia/authing-apisix-java-agent

    4.1 业务架构

    系统整体包含了三大部分:Authing 服务集群、Authing 插件适配服务以及 APISIX 网关,本方案建立需要配置和开发的部分有四个部分,Authing API 权限结构配置、APISIX 插件和路由配置、APISIX 插件开发部署以及业务适配服务开发,其中业务适配服务包含了认证和授权的主要逻辑(使用单独服务承载),避免了插件的频繁更新和部署。

    这里需要说明的是,之所以采用 Adapter 的方式来实现,是因为插件我们并不希望经常变动,但需求可能是无法避免的需要经常变动,所以我们将具体的鉴权逻辑放在 Adapter ,插件只实现请求转发和根据 Adapter 的返回结果决定是否放行,同时无状态的插件可以让我们实现更多的场景复用和能力扩展,例如进行鉴权结果的缓存实现,后续只需维护 Adapter 即可。

    当然我们也可将具体的逻辑放在插件里。

    注意,本教程只用于与 APISIX 和 Authing 进行集成,对于生产环境使用,您需要自行开发插件并保证其安全性及可用性等,本文档不承诺此插件可以用于生产环境

    • APISIX 基础环境搭建
    git clone https://github.com/apache/apisix-docker.git
    cd apisix-docker/example
    docker-compose -p docker-apisix up -d
    

    到这里可以使用 docker ps 查看 apisix docker 进程启动状态, 随后访问 localhost:9000 可以进入 dashboard 界面进行路由和插件的配置。

    4.2 在 Authing 对 API 进行管理

    登录 Authing 官网:www.authing.com ,进行以下操作:

    • 4.2.1 创建应用

    配置 Token 签名算法为 RS256 及校验 AccessToken 的方式为 none 。

    • 4.2.2 创建用户

    进入 Authing 控制台-用户管理-用户列表-点击创建用户后,可以根据不同方式(用户名、手机号、邮箱)创建测试用户,如下图所示:

    • 4.2.3 创建 API

    进入 Authing 控制台-权限管理-创建资源,可以选择创建树数据类型的资源,如下图所示:

    • 4.2.4 创建策略

    进入权限管理-数据资源权限-数据策略标签,可以点击创建策略来新建数据访问策略,如下图所示。策略包含了对应的权限空间中定义的数据以及操作,创建后能够基于此策略对不同对象(用户、角色、用户组等)进行授权管理。

    • 4.2.5 API 授权

    4.3 APISIX 路由和 SOCK 配置

    • 4.3.1 SOCK 配置

    APISIX 使用 unix sock 与插件进程通信,因此需要配置对应的 sock 端口:

    需要将宿主机上的 sock 文件挂载到容器里,插件启动的时候会在宿主机上创建这个 sock 文件,此处需要注意的是,若 APISIX 是先于插件启动的,当插件启动后,则需要重启下 APISIX 容器,确保插件先于 APISIX 启动。

    文件位置: /apisix-docker/example/docker-compose.yml apisix 部分

      apisix:
        image: apache/apisix:latest
        restart: always
        volumes:
          - ./apisix_log:/usr/local/apisix/logs
          - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
          - /tmp/runner.sock:/tmp/runner.sock
    
    • 4.3.2 路由配置

    X-API-KEY: /apisix/apisix-docker/example/apisix_conf/config.yaml

    curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
    {
      "uri": "/*",
      "plugins": {
        "ext-plugin-pre-req": {
          "conf": [
     {
              "name": "authing_agent",
          "value": "{\"url\": \"{适配服务的访问地址}\",\"user_pool_id\": \"{用户池 ID}\",\"user_pool_secret\": \"{用户池密钥}\"}"
        }
          ]
        }
      },
      "upstream": {
            "type": "roundrobin",
            "nodes": {
                "httpbin.org:80": 1
            }
        }
    }'
    

    ext-plugin-pre-req 是需要启用的插件类型, 在配置 conf 中需要确定两个变量:

    "name": 插件名称
    "value": "{"url": "适配服务的访问地址","user_pool_id": "用户池 ID","user_pool_secret": "用户池密钥"}"

    其中,访问地址格式为 {{domain}}:{{port}}/{{path}}

    例如: "{"url": "http://192.168.1.123:8080/isAllow","user_pool_id": "124u2353h2t24he2u349382u152","user_pool_secret": "6435462313i5412njburh2u34"}"

    4.4 APISIX 插件开发和部署

    • 4.4.1 建立插件工程目录

    git clone https://github.com/apache/apisix-python-plugin-runner.git 进入目录 make setup 进入目录 make install 进入目录并修改 apisix/plugins/rewrite.py 文件,将请求参数传递到 Authing

    • 4.4.2 编写 Agent (python) 插件代码

    可使用其他语言实现例如 Java 、Go 、Lua

    之所以采用 Python 的原因是因为环境初始化比较简单,可以让开发者快速了解 APISIX 的插件的开发机制。

    https://apisix.apache.org/docs/apisix/external-plugin/

    from typing import Any
    from apisix.runner.http.request import Request
    from apisix.runner.http.response import Response
    from apisix.runner.plugin.core import PluginBase
    import json
    import requests
    import json
    
    def isAllow(request,config):
        return requests.request("POST", 
            config.get("url"), 
            headers={
            'Content-Type': 'application/json'
            }, 
            data=json.dumps({
            "request": request,
            "pluginConfig": config
            }))
    
    
    
    class Rewrite(PluginBase):
    
        def name(self) -> str:
            return "authing_agent"
    
        def config(self, conf: Any) -> Any:
            return conf
    
        def filter(self, conf: Any, request: Request, response: Response):
            # 组装 Adapter 请求参数
            authing_request = {
            "uri": request.get_uri(),
            "method": request.get_method(),
            "args":request.get_args(),
            "headers":request.get_headers(),
            "request_id":request.get_id(),
            "host":request.get_var("host"),
            "remote_addr": request.get_remote_addr(),
            "configs": request.get_configs()
            }
            # 接收 Adapter 响应判断是否放行
            authing_response = isAllow(authing_request,eval(conf))
            if authing_response.text != "ok":
                response.set_status_code(authing_response.status_code)
                response.set_body(authing_response.text)            
    
    • 4.4.3 运行 Agent 插件
    nohup make dev & #后台运行 agent 程序
    

    4.5 适配器开发

    • 4.5.1 通信接口设计

    启动代理 Authing 服务(自行实现对应接口,以 springboot 为例,接口结构如下)

    • 4.5.2 部分 JAVA 文件列出如下

    IsAllowController.java

    package cn.authing.apisix.adapter.controller;
    
    import cn.authing.apisix.adapter.entity.APISIXRquestParams;
    import cn.authing.sdk.java.client.ManagementClient;
    import cn.authing.sdk.java.dto.CheckPermissionDto;
    import cn.authing.sdk.java.dto.CheckPermissionRespDto;
    import cn.authing.sdk.java.dto.CheckPermissionsRespDto;
    import cn.authing.sdk.java.model.ManagementClientOptions;
    import cn.hutool.http.HttpStatus;
    import cn.hutool.http.HttpUtil;
    import com.google.gson.Gson;
    import com.nimbusds.jose.JOSEException;
    import com.nimbusds.jose.JWSAlgorithm;
    import com.nimbusds.jose.JWSObject;
    import com.nimbusds.jose.jwk.source.JWKSource;
    import com.nimbusds.jose.jwk.source.RemoteJWKSet;
    import com.nimbusds.jose.proc.BadJOSEException;
    import com.nimbusds.jose.proc.JWSKeySelector;
    import com.nimbusds.jose.proc.JWSVerificationKeySelector;
    import com.nimbusds.jose.proc.SecurityContext;
    import com.nimbusds.jwt.JWTClaimsSet;
    import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
    import com.nimbusds.jwt.proc.DefaultJWTProcessor;
    import jakarta.servlet.http.HttpServletResponse;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.util.StopWatch;
    import org.springframework.util.StringUtils;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.net.MalformedURLException;
    import java.net.URL;
    import java.text.ParseException;
    import java.util.Arrays;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    
    /**
     * @author Gao FeiHu
     * @version 1.0.0
     * @date 2022.12.22
     * @email [email protected]
     */
    @RestController
    @Slf4j
    public class IsAllowController {
    
        /**
         * 用户池 ID
         */
        public static String ACCESS_KEY_ID = "";
        /**
         * 用户池密钥
         */
        public static String ACCESS_KEY_SECRET = "";
        /**
         * Authing SDK
         * See
         * https://docs.authing.cn/v3/reference/
         */
        ManagementClient managementClient;
    
        /**
         * 初始化 ManagementClient
         *
         * @param ak  用户池 ID
         * @param aks 用户池密钥
         */
        public void init(String ak, String aks) {
            log.info("init ManagementClient ......");
            try {
                // 保存用户池 ID 和密钥
                ACCESS_KEY_ID = ak;
                ACCESS_KEY_SECRET = aks;
                // 初始化
                ManagementClientOptions options = new ManagementClientOptions();
                options.setAccessKeyId(ak);
                options.setAccessKeySecret(aks);
                managementClient = new ManagementClient(options);
            } catch (Exception e) {
                e.printStackTrace();
                System.err.println("初始化 managementClient 失败,可能无法请求!");
            }
        }
    
        /**
         * 是否放行
         *
         * @param apisixRquestParams 请求 body ,包含了 APISIX 插件的配置以及请求上下文
         * @param response           HttpServletResponse
         * @return 200 OK 放行
         * 403 forbidden 禁止访问
         * 500 internal server error 请求错误 可根据实际需求放行或拒绝
         */
        @PostMapping("/isAllow")
        public Object isAllow(@RequestBody APISIXRquestParams apisixRquestParams, HttpServletResponse response) {
    
            // 请求计时器
            StopWatch stopWatch = new StopWatch();
            stopWatch.start();
    
            // 请求 ID 与 APISIX 一致
            String requestID = apisixRquestParams.getRequest().getRequest_id();
    
            log.info("{} ==> 请求入参 : {} ", requestID, new Gson().toJson(apisixRquestParams));
    
            try {
                // 0. 若插件为多实例用于实现不同业务逻辑,此处可对应修改为多实例模式
                if (managementClient == null || !ACCESS_KEY_ID.equals(apisixRquestParams.getPluginConfig().get("user_pool_id"))) {
                    init((String) apisixRquestParams.getPluginConfig().get("user_pool_id"), (String) apisixRquestParams.getPluginConfig().get("user_pool_secret"));
                }
    
                // 1. 拿到 accessToken
                String authorization = (String) apisixRquestParams.getRequest().getHeaders().get("authorization");
                if (!StringUtils.hasLength(authorization)) {
                    return result(response, stopWatch, requestID, HttpStatus.HTTP_UNAUTHORIZED, "HTTP_UNAUTHORIZED");
                }
    
                String accessToken = authorization;
                if (authorization.startsWith("Bearer")) {
                    accessToken = authorization.split(" ")[1].trim();
                }
    
    
                log.info("{} ==> accessToken : {} ", requestID, accessToken);
                // 2. 解析 accessToken 拿到应用 ID 和用户 ID
                JWSObject parse = JWSObject.parse(accessToken);
                Map<String, Object> payload = parse.getPayload().toJSONObject();
                String aud = (String) payload.get("aud");
                String sub = (String) payload.get("sub");
    
                // 3. 校验 accessToken
                // 在线校验
                String result = onlineValidatorAccessToken(accessToken, aud);
                log.info("{} ==> accessToken 在线结果 : {} ", requestID, result);
                if (!result.contains("{\"active\":true")) {
                    return result(response, stopWatch, requestID, HttpStatus.HTTP_UNAUTHORIZED, "HTTP_UNAUTHORIZED");
                }
    
    //            // 离线校验
    //            if (null == offlineValidatorAccessToken(accessToken, aud)) {
    //                return result(response, stopWatch, requestID, HttpStatus.HTTP_UNAUTHORIZED, "HTTP_UNAUTHORIZED");
    //            }
    
                // 4. 获取到 APISIX 中的请求方法,对应 Authing 权限中的 action
                String action = apisixRquestParams.getRequest().getMethod();
    
                // 5. 获取到 APISIX 中的请求路径
                String resource = apisixRquestParams.getRequest().getUri();
    
                // 6. 去 Authing 请求,判断是否有权限
                // TODO 可在此添加 Redis 对校验结果进行缓存
                CheckPermissionDto reqDto = new CheckPermissionDto();
                reqDto.setUserId(sub);
                reqDto.setNamespaceCode(aud);
                reqDto.setResources(Arrays.asList(resource.substring(1, resource.length())));
                reqDto.setAction(action);
                CheckPermissionRespDto checkPermissionRespDto = managementClient.checkPermission(reqDto);
                log.info(new Gson().toJson(checkPermissionRespDto));
    
                // 7. 由于我们是单个 resource 校验,所以只需要判断第一个元素即可
                List<CheckPermissionsRespDto> resultList = checkPermissionRespDto.getData().getCheckResultList();
                if (resultList.isEmpty() || resultList.get(0).getEnabled() == false) {
                    return result(response, stopWatch, requestID, HttpStatus.HTTP_FORBIDDEN, "HTTP_FORBIDDEN");
                }
    
                return result(response, stopWatch, requestID, HttpStatus.HTTP_OK, "ok");
    
            } catch (Exception e) {
                e.printStackTrace();
                log.error("请求错误!", e);
                return result(response, stopWatch, requestID, HttpStatus.HTTP_INTERNAL_ERROR, e.getMessage());
            }
        }
    
    
        public String result(HttpServletResponse response, StopWatch stopWatch, String requestID, int status, String msg) {
            stopWatch.stop();
            log.info("{} ==> 请求耗时:{} , 请求出参 : http_status_code={},msg={} ", requestID, stopWatch.getTotalTimeMillis() + "ms", status, msg);
            response.setStatus(status);
            return msg;
        }
    
    
        public String onlineValidatorAccessToken(String accessToken, String aud) {
            HashMap<String, Object> paramMap = new HashMap<>();
            paramMap.put("token", accessToken);
            paramMap.put("token_type_hint", "access_token");
            paramMap.put("client_id", aud);
            return HttpUtil.post("https://api.authing.cn/" + aud + "/oidc/token/introspection", paramMap);
    
        }
    
        public JWTClaimsSet offlineValidatorAccessToken(String accessToken, String aud) {
            try {
                ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
                        new DefaultJWTProcessor<>();
                JWKSource<SecurityContext> keySource =
                        null;
    
                keySource = new RemoteJWKSet<>(new URL("https://api.authing.cn/" + aud + "/oidc/.well-known/jwks.json"));
    
                JWSAlgorithm expectedJWSAlg = JWSAlgorithm.RS256;
    
                JWSKeySelector<SecurityContext> keySelector =
                        new JWSVerificationKeySelector<>(expectedJWSAlg, keySource);
    
                jwtProcessor.setJWSKeySelector(keySelector);
    
                return jwtProcessor.process(accessToken, null);
            } catch (MalformedURLException e) {
                e.printStackTrace();
            } catch (ParseException e) {
                e.printStackTrace();
            } catch (BadJOSEException e) {
                e.printStackTrace();
            } catch (JOSEException e) {
                e.printStackTrace();
            } finally {
                return null;
            }
        }
    }
    

    APISIXRquestParams.java

    package cn.authing.apisix.adapter.entity;
    
    import lombok.Data;
    import lombok.ToString;
    
    import java.util.Map;
    
    /**
     * APISIX 请求实体类
     */
    @Data
    @ToString
    public class APISIXRquestParams {
        /**
         * APISIX 请求上下文
         */
        APISIXRequest request;
        /**
         * 插件配置
         */
        Map<String, Object> pluginConfig;
    
    }
    

    APISIXRequest.java

    package cn.authing.apisix.adapter.entity;
    
    import lombok.Data;
    import lombok.ToString;
    
    import java.util.Map;
    
    @Data
    @ToString
    public class APISIXRequest {
        private String uri;
        private String method;
        private String request_id;
        private String host;
        private String remote_addr;
        private Map<String, Object> args;
        private Map<String, Object> headers;
        private Map<String, Object> configs;
    }
    

    4.6 访问测试

    • 4.6.1 未认证

    • 4.6.2 无权限

    • 4.6.3 认证通过并成功访问

    404 是因为上游服务没有这个接口,但认证和 API 鉴权已经通过

    05 总结

    如果您需要对 API 进行细颗粒度的管理可以通过本方案来实现,我们可以在 Adapter 实现更加细粒度的 API 访问控制以及更加场景化的权限方案。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2963 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 14:58 · PVG 22:58 · LAX 06:58 · JFK 09:58
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.