一步步学习Keycloak(授权篇)

上一篇详细介绍了如何在Nginx中集成Keycloak来进行应用程序的认证,将认证和授权服务从业务中剥离出来,减少了代码的侵入,但在实际的产品中,仅仅进行认证服务肯定是不够的,需要有授权功能。这一篇文章将介绍如何在Nginx中利用Keycloak完成对服务或者应用的授权。刚开始的设想是在Keycloak中对资源进行授权配置,然后利用上一篇介绍的开源库oidc-proxy来完成对应用或服务的认证和授权,但在实验的过程中,发现无论在Keycloak中如何配置权限,但都能访问到后端的服务,起初怀疑是Keycloak的权限配置问题,但通过网上的Springboot中集成Keycloak的例子验证Keycloak配置没有任何问题,因此就怀疑是oidc-proxy这个库根本就没有授权功能,因此只能下载这个库的源码仔细分析,发现这个库确实没有授权功能,只有认证功能,万般无奈,只能在这个库的基础上继续完善授权功能,下面会详细介绍如何在oidc-proxy现有功能的基础上增加授权功能,并以我第一篇文章的实际例子来演示这个过程。

Keycloak支持的授权策略

Keycloak支持细粒度的授权策略,并且能够组合不同的访问控制机制,例如:

  • Attribute-based access control (ABAC): 基于属性的安全控制
  • Role-based access control (RBAC): 基于角色的安全控制
  • User-based access control (UBAC): 基于用户的安全控制
  • Context-based access control (CBAC): 基于上下文的安全控制
  • Rule-based access control: 基于规则的安全控制
    使用Javascript
    使用 JBoss Drools
  • Time-based access control: 基于时间的安全控制

  • 通过策略提供程序服务提供程序接口(SPI)支持自定义访问控制机制(ACMs)

  • Keycloak基于一组管理UI和RESTful API,为受保护资源和作用域创建权限,将这些权限与授权策略相关联以及在应用程序和服务中强制执行授权决策。对于基于RESTful的资源服务器,该信息通常从安全令牌获取,通常在每次请求发送到服务器时作为承载令牌发送。对于依赖会话对用户进行身份验证的Web应用程序,该信息通常存储在用户的会话中,并从那里为每个请求检索。

    Keycloak授权架构

    从设计角度来看,授权服务基于一组明确定义的授权模式,提供以下功能:

    策略管理点(Policy Administration Point/PAP)

    提供基于Keycloak管理控制台的一组UI,以管理资源服务器,资源,范围,权限和策略。部分内容也可以通过使用Protection API远程完成。

    策略决策点(PDP)

    提供可分发的策略决策点,指向发送授权请求的位置,并根据请求的权限相应地评估策略。

    策略执行点(PEP)

    提供不同环境的实现,以在资源服务器端实际执行授权决策。Keycloak提供了一些内置的Policy Enforcer。

    策略信息点(PIP)

    基于Keycloak Authentication Server,可以从身份和运行时环境中获取属性。

    Keycloak授权流程

    主要步骤为三个流程,以了解如何使用Keycloak为您的应用程序启用细粒度授权:

  • 资源管理
  • 权限和策略管理
  • 策略执行
  • 资源管理

    首先,您需要指定Keycloak您希望保护的内容,通常代表Web应用程序或一组一个或多个服务。使用Keycloak管理控制台管理资源服务器。在那里,您可以启用任何已注册的客户端应用程序作为资源服务器,并开始管理您要保护的资源和范围。

    资源可以是网页,RESTFul资源,文件系统中的文件,EJB等。它们可以表示一组资源(就像Java中的类一样),也可以表示单个特定资源。比如我们例子中某个用户能访问或控制某个区域的设备,区域和设备等都可以看作是资源。范围通常表示可以对资源执行的操作,但它们不限于此。您还可以使用范围来表示资源中的一个或多个属性。

    权限策略管理

    定义资源服务器和要保护的所有资源后,必须设置权限和策略。此过程涉及实际定义管理资源的安全性和访问要求的所有必要步骤。策略定义了访问或执行某些操作(资源或范围)必须满足的条件,但它们与它们保护的内容无关。它们是通用的,可以重用来构建权限甚至更复杂的策略。Keycloak提供了一些内置策略类型(及其各自的策略提供者),比如基于Role的,基于Group的,或者基于用户的策略,涵盖了最常见的访问控制机制。您甚至可以根据使用JavaScript或JBoss Drools编写的规则创建策略。

    定义策略后,即可开始定义权限。权限与他们保护的资源相结合。在此处指定要保护的内容(资源或范围)以及授予或拒绝权限必须满足的策略。后面会有详细的例子介绍。

    策略执行

    策略实施涉及实际执行资源服务器的授权决策的必要步骤。这是通过在资源服务器上启用策略强制点或PEP来实现的,该策略强制点或PEP能够与授权服务器通信,请求授权数据并根据服务器返回的决策和权限控制对受保护资源的访问。Keycloak提供了一些内置的Policy Enforcer实现,您可以使用它们来保护您的应用程序,具体取决于它们运行的平台。

    Keycloak的授权服务

    授权服务由以下RESTFul端点组成:

  • 令牌端点
  • 资源管理端点
  • 权限管理端点
    这些服务中的每一个都提供了一个特定的API,涵盖了授权过程中涉及的不同步骤。
  • 令牌端点

    OAuth2客户端(例如前端应用程序)可以使用令牌端点从服务器获取访问令牌,并使用这些相同的令牌来访问受资源服务器保护的资源(例如后端服务)。
    同样,Keycloak授权服务提供对OAuth2的扩展,以允许基于与所请求的资源或范围相关联的所有策略的处理来发布访问令牌。

    这意味着资源服务器可以根据服务器授予的权限强制访问其受保护资源,并由访问令牌持有。
    在Keycloak授权服务中,具有权限的访问令牌称为请求方令牌或简称RPT。

    Protection API

    Protection API是一组符合UMA的端点,为资源服务器提供操作,以帮助他们管理与之关联的资源,范围,权限和策略。只允许资源服务器访问此API,这也需要uma_protection范围。
    Protection API提供的操作可以分为两大类:

    资源管理

  • 创建资源
  • 删除资源
  • 根据ID查找
  • 查询
  • 权限管理

  • 签发许可证

  • 在Nginx中实现授权

    结合上面介绍的Keycloak的概念和架构,要想在Nginx中集成Keycloak的授权功能,需要有以下步骤:
    第一步,管理项目中的资源,通过Keycloak的控制台操作,也可以通过Protection API完成
    第二步,管理权限策略,即绑定资源和策略,定义权限,通过Keycloak的控制台操作,也可以通过Protection API完成。
    第三步,在Nginx中实现策略执行,根据用户的token,通过授权服务验证用户是否有资源的访问权限。

    实例介绍

    假如在一个控制系统中,需要根据用户的权限对某个区域的某些设备进行访问控制,UserA可以访问ZoneA的设备,但不能控制ZoneA的设备,也不能访问和控制ZoneB的设备。UserB可以访问和控制ZoneB的设备,但不能访问和控制ZoneA的设备。
    在这个例子中,用户包括UserA和UserB,资源包括ZoneA,ZoneB,

    在这个例子中,用户包括UserA和UserB,资源包括ZoneA,ZoneB,
    为了简化这个实例,假设访问Zone的服务为/zone,用户将zone作为请求header传入,zone为zonea或者zoneb,Nginx将从请求header中读取zone信息,并根据用户的权限来决定是否有访问此区域的权限。

    第一步,配置Keycloak

    这一步请参考上一篇的实例,此处不再做详细介绍。

  • 安装并配置Keycloak

  • 创建Realm,命名为nginx

  • 创建client,命名为nginx

  • 创建用户,包括UserA,UserB

  • 第二步,创建资源

    选择nginx client,然后点击进入,选择Authorization标签页,然后选择Resources标签页,增加zonea和zoneb两个资源,如下图.

    第三步,创建Policy

    下拉”Create Polocy“,可以看到Keycloak提供了很多内置的Policy,包括Role,User,Group,Time,Javascript,Aggregated等,此实例中只演示User类型的policy,选择User,出现如下的界面,分别创建policyA和policyB,对应的用户分别选择usera和userb。

    第四步,配置权限

    选择"Permissions“标签页,添加权限,在下拉”Create Permission“,出现Resouce-based和Role-based,此实例中选择Resource-based权限,出现如下界面,分别创建permissionA和permissionB,对应的resource分别为zonea和zoneb。

    第五步,验证权限

    完成权限的配置后,可以通过evaluate功能来测试权限配置是否正确,选择”evaluate“标签页,分别使用usera来测试对zonea和zoneb的访问,分别出现的结果如下,说明配置正确。

    这一步也是最为重要的一步,刚开始我们在对服务进行访问时,发现无论是哪个用户都能访问到后端的服务,因为最初认为肯定有授权功能,因此这个是Keycloak最基本的功能,oidc-proxy没有理由不支持,因此花费了很多时间在怀疑是不是Keycloak的配置不正确,试了各种配置方法,最后使用Springboot的Keycloak adapter来验证我们配置的服务,发现能正确的进行授权,因此,断定是oidc-proxy的问题,为此去仔细的读了以下它的源码,

  • 在nginx的配置文件sites/proxy.conf文件中加入了请求过滤

  • location / {
          access_by_lua_file lua/auth.lua;

          set $reverse_proxy_host $proxy_host;

          if ($add_host_header = "true") {
            set $reverse_proxy_host $http_host;
          }

          proxy_set_header Host $reverse_proxy_host; 
          proxy_pass $proxy_protocol://$proxy_host:$proxy_port;
        }

  • auth.lua中调用了authenticate方法

  • local opts = {
        redirect_uri_path = os.getenv("OID_REDIRECT_PATH") or "/redirect_uri",
        discovery = os.getenv("OID_DISCOVERY"),
        client_id = os.getenv("OID_CLIENT_ID"),
        client_secret = os.getenv("OID_CLIENT_SECRET"),
        token_endpoint_auth_method = os.getenv("OIDC_AUTH_METHOD") or "client_secret_basic",
        renew_access_token_on_expiry = os.getenv("OIDC_RENEW_ACCESS_TOKEN_ON_EXPIERY") ~= "false",
        scope = os.getenv("OIDC_AUTH_SCOPE") or "openid",
        iat_slack = 600,
    }

    -- call authenticate for OpenID Connect user authentication
    local res, err, _target, session = require("resty.openidc").authenticate(opts)

    其中discovery是Keycloak提供的一系列endpoint的json对象,包括authorization_endpoint,token_endpoint,userinfo_endpoint等,用户可以访问http://{keycloak ip}:8080/auth/realms/master/.well-known/openid-configuration来查看详细的信息。

  • openidc.lua中实现了authenticate方法,这个方法内容比较多,主要的逻辑是实现了openid的认证逻辑,保持session等,其中只调用了token_endpoint,并没有调用authorization_endpoint,代码片段如下:

  • local json
      json, err = openidc.call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method)
      if err then
        return nil, err, session.data.original_url, session
      end

      local id_token, err = openidc_load_and_validate_jwt_id_token(opts, json.id_token, session);
      if err then
        return nil, err, session.data.original_url, session
      end

      -- mark this sessions as authenticated
      session.data.authenticated = true
      -- clear state and nonce to protect against potential misuse
      session.data.nonce = nil
      session.data.state = nil
      if store_in_session(opts, 'id_token') then
        session.data.id_token = id_token
      end

      if store_in_session(opts, 'user') then
        -- call the user info endpoint
        -- TODO: should this error be checked?
        local user
        user, err = openidc.call_userinfo_endpoint(opts, json.access_token)

        if err then
          log(ERROR, "error calling userinfo endpoint: " .. err)
        elseif user then
          if id_token.sub ~= user.sub then
            err = "\"sub\" claim in id_token (\"" .. (id_token.sub or "null") .. "\") is not equal to the \"sub\" claim returned from the userinfo endpoint (\"" .. (user.sub or "null") .. "\")"
            log(ERROR, err)
          else
            session.data.user = user
          end
        end
      end

      if store_in_session(opts, 'enc_id_token') then
        session.data.enc_id_token = json.id_token
      end

      if store_in_session(opts, 'access_token') then
        session.data.access_token = json.access_token
        session.data.access_token_expiration = current_time
            + openidc_access_token_expires_in(opts, json.expires_in)
        if json.refresh_token ~= nil then
          session.data.refresh_token = json.refresh_token
        end
      end

      if opts.lifecycle and opts.lifecycle.on_authenticated then
        opts.lifecycle.on_authenticated(session)
      end

      -- save the session with the obtained id_token
      session:save()

  • 从上面的代码中可以分析出oidc-proxy中没有实现authorization,因此完善了此功能,实现很简单,只需要请求authorization_endpoint来获取当前用户是否有对指定资源的访问权限。为了简化实现,我将需要访问的资源使用header方式传入,然后进行验证

  • local headers = ngx.req.get_headers()
    local zone= headers["zone"]

    ngx.log(ngx.INFO, "zone=", zone)

    local url = ngx.var.request_uri
    ngx.log(ngx.INFO, "request uri=", url)
    local opts_permission = {
        discovery = os.getenv("OID_DISCOVERY")
    }
    local body_permission = {
        grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket",
        audience = os.getenv("OID_CLIENT_ID"),
        response_mode = "decision",
        permission = zone
    }
    local opts = {
        redirect_uri_path = os.getenv("OID_REDIRECT_PATH") or "/redirect_uri",
        discovery = os.getenv("OID_DISCOVERY"),
        client_id = os.getenv("OID_CLIENT_ID"),
        client_secret = os.getenv("OID_CLIENT_SECRET"),
        token_endpoint_auth_method = os.getenv("OIDC_AUTH_METHOD") or "client_secret_basic",
        renew_access_token_on_expiry = os.getenv("OIDC_RENEW_ACCESS_TOKEN_ON_EXPIERY") ~= "false",
        scope = os.getenv("OIDC_AUTH_SCOPE") or "openid",
        iat_slack = 600,
    }

    local response, err = require("resty.openidc").call_checkpermission_endpoint(opts, session.data.access_token, body_permission)
    if err or not response.result then
       ngx.log(ngx.INFO, "Access permission error, result= ", response.result, err);
       ngx.status = 401
       ngx.header.content_type = 'text/html'
       ngx.say("User is not permitted to access the region ", region)
       ngx.exit(ngx.HTTP_FORBIDDEN)
    end

  • 在openidc.lua中实现checkpermission逻辑

  • --make a call to get permission endpoint
    function openidc.call_checkpermission_endpoint(opts, access_token, body)
      log(DEBUG,"enter into openidc.call_permissions_endpoint function")

      local err = openidc_ensure_discovered_data(opts)
      local result = { ["result"] = false }
      if err then
        log(ERROR, "ensure discover data failed", err)
        return result, error
      end
      if not opts.discovery.token_endpoint then
        log(ERROR, "no token endpoint supplied")
        return result, "no token endpoint supplied"
      end
      if not access_token then
        access_token = session.data.access_token
      end
      local headers = {
        ["Content-Type"] = "application/x-www-form-urlencoded",
        ["Authorization"] = "Bearer " .. access_token
      }
      local httpc = http.new()
      openidc_configure_timeouts(httpc, opts.timeout)
      openidc_configure_proxy(httpc, opts.proxy_opts)
      local res, err = httpc:request_uri(opts.discovery.token_endpoint, decorate_request(opts.http_request_decorator, {
        method = "POST",
        body = ngx.encode_args(body),
        headers = headers,
        ssl_verify = (opts.ssl_verify ~= "no")
      }))
      if not res then
        err = "accessing " .. opts.discovery.token_endpoint .. " endpoint  failed: "
        log(ERROR, err)
        return result, err
      end
      log(DEBUG, opts.discovery.token_endpoint .. " endpoint response: ", res.body)
      local response, err = openidc_parse_json_response(res)
      if err then
        log(ERROR, err)
        return result, err
      end
      return response, err
    end

    你可能感兴趣的:(一步步学习Keycloak(授权篇))