用Auth0保护Rails的API(详细指南)

456 阅读7分钟

如果你想在互联网上公开暴露一个API,授权迟早会成为你的要求。你要验证消费API的客户是否有适当的权限。

本指南正是关于这一点。我们将使用Ruby On Rails来保护一个API,用Auth0作为授权服务器。

Github上有一个代码库,你可以跟着它走。

保护API的安全

我还没有解释我所说的保护API是什么意思。从本质上讲,我们要确保受保护的路由只能被有足够权限的用户访问。

当涉及到安全问题时,一般认为推出自己的定制实现是个坏主意。相反,我正在使用OAuth,一个经过战斗考验的、广泛使用的网络应用程序授权框架。

在这种情况下,Auth0扮演了授权服务器的角色,并将很大一部分工作从我们这里抽象出来。这样一来,我们就可以专注于为我们的用户提供价值。

在不深入了解OAuth如何工作的情况下,我们假设对我们的API的调用将包括一个使用行业标准的JWT格式的承载令牌。该令牌包含一系列关于其发行者的声明,它的有效期是多久,以及它授予了什么权利。让我们来看看一个样本令牌。

{
  "iss": "https://yourTenant.eu.auth0.com/",
  "sub": "zHwnsh0j2sTj4u3ss6YedSFrzyb2",
  "aud": "https://targetAudience.com",
  "iat": 1621369130,
  "exp": 1791455530,
  "azp": "ThEkgdG1NndLlWoNMcEdEr2KJIs9vKad",
  "scope": "openid profile read:admin-messages",
  "permissions": ["read:admin-messages"]
}

我们将实现对该令牌的验证,并拒绝不具备所需权限的请求。

入门

我们将从我们的基础应用程序开始,用Rails 6引导。这个分支是一个很好的起点。你可以通过在终端窗口运行以下命令来下载它。

git clone -b starter --single-branch https://github.com/auth0-blog/rails-api-auth0.git

该API有三个端点,具有不同的保护级别。

  • /api/messages/public:公共路线。
  • /api/messages/protected:需要一个有效的访问令牌。
  • /api/messages/admin:需要一个有效的访问令牌。由于Auth0使用JWT作为其访问令牌格式,我们可以检查它,并确保它有一个permissions ,其中包含范围 read:admin-messages.

运行应用程序

要运行该应用程序,我们首先需要正确的ruby版本。最简单的方法是使用一个版本管理器,如Rbenv。一旦你安装了它,在版本库内运行这个命令来安装正确的ruby版本。

rbenv install

安装应用程序的依赖项。

bundle install

最后,运行该应用程序。

bin/rails s

你可以用curl 来验证该应用程序是否正常工作。

curl localhost:6060/api/messages/public

该命令将返回一个200的代码,外加信息。

{"message": "The API doesn't require an access token to share this message."}

在Auth0上创建一个API

为了保证Auth0的API安全,你需要一个Auth0账户。如果你还没有,你现在就可以免费注册了。在Auth0仪表板API部分,点击创建API。为你的API提供一个名称和一个标识符。稍后在配置访问令牌验证时,你将使用该标识符作为audience 。将签名算法设为RS256。

Create API

一旦你创建了API,去API细节中的权限标签,添加一个名为 read:admin-messages.

Create permission

注意:当你在Auth0仪表板上时,注意你的Auth0域。你很快就会需要它。这是一个字符串,其形式为 YOUR-TENANT-NAME.auth0.com其中 YOUR-TENANT-NAME是你在Auth0创建账户时提供的名称。欲了解更多信息,请查看文档

准备应用程序的验证

好了,我们的应用程序已经准备好了,而且急需一些安全保障。在这之前,我们需要添加一些配置。

创建API后,你尽职尽责地存储了domainaudience ,对吗?让我们来使用它们。在Rails世界中,惯例是在config 文件夹中添加这个,使用YAML。该文件被称为 config/auth0.yml.

我们不想在代码中存储凭证,所以我们将把这些值导出为环境变量,命名为 AUTH0_DOMAINAUTH0_AUDIENCE.该配置使用这些值,并使它们安全地远离源码控制!

development:
  issuerUri: <%= ENV["AUTH0_DOMAIN"] %>
  audience: <%= ENV["AUTH0_AUDIENCE"] %>

现在,设置 AUTH0_DOMAINAUTH0_AUDIENCE环境变量设置为domainaudience ,作为你的API的值。

有了这些,应用程序就可以与Auth0连接了。让我们继续执行。

验证访问令牌

在我们开始之前,让我们先谈谈rails的认证框架。生态系统中有一堆替代方案,比如CanCanCandevise

然而,我们的情况对这些框架来说是一个尴尬的选择。我们不想接管用户管理。我们只想验证由授权服务器发出的令牌。在这种情况下,使用一个框架不会像Spring Security那样给我们带来很多好处。这就是为什么我们只依赖一个额外的宝石,jwt

实现授权检查的代码存在于add-authorization分支中。让我们看看它是如何工作的

利用JWT库

jwt 库带来了很多功能,使我们的API能够验证和管理JWT格式的令牌,比如由Auth0发布的令牌。我们需要做三件事。

  • 我们需要从Auth0获取JWKS配置,这样我们就知道验证访问令牌签名的公钥是什么。
  • 有了公钥、发行者和受众,我们要验证令牌。
  • 在验证令牌后,我们要对其进行解码,以访问permissions (或令牌中包含的任何其他信息,真的)。

解码令牌

让我们进入正题。我们在lib 文件夹中实现验证/解码逻辑,文件名为 json_web_token.rb.我们从获取JWKS的数据开始。

# lib/json_web_token.rb
require 'jwt'
require 'net/http'

class JsonWebToken
  class << self
    def algorithm
      'RS256'
    end

    def key(header)
      jwks_hash[header['kid']]
    end

    def jwks_hash
      jwks_raw = Net::HTTP.get URI("#{issuer}.well-known/jwks.json")
      jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
      jwks_keys.map do |k|
        [
          k['kid'],
          OpenSSL::X509::Certificate.new(Base64.decode64(k['x5c'].first)).public_key
        ]
      end.to_h
    end

    def issuer
      "https://#{Rails.application.config.x.auth0.issuerUri}/"
    end
  end
end

正如你所看到的,通过issuerUri ,我们知道在哪里可以找到数据。然后,我们对密钥进行解析,以便我们可以在库中消费它。

下一步是给类添加一个额外的方法,verify ,它同时验证和解码令牌。

# lib/json_web_token.rb
class JsonWebToken
  class << self
    def verify(token)
      JWT.decode(token, nil,
                 true, # Verify the signature of this token
                 algorithm: algorithm,
                 iss: Rails.application.config.x.auth0.issuerUri,
                 verify_iss: true,
                 aud: Rails.application.config.x.auth0.audience,
                 verify_aud: true) do |header|
        key(header)
      end
    end
    
    # ... existing code ...
    
  end
end

这就是我们实际验证所需的所有代码。很好,不是吗?但我们的控制器仍然是不安全的。不过,不会太久了。

保护端点

为了尽可能的方便,我们将创建一个钩子,在任何需要验证的路由之前运行。为了让每个控制器都能使用它,它位于 app/controllers/application_controller.rb:

# app/controllers/application_controller.rb
require 'json_web_token'

class ApplicationController < ActionController::API
  def authorize!
    valid, result = verify(raw_token(request.headers))

    head :unauthorized unless valid

    @token ||= result
  end

  private

  def verify(token)
    payload, = JsonWebToken.verify(token)
    [true, payload]
  rescue JWT::DecodeError => e
    [false, e]
  end

  def raw_token(headers)
    return headers['Authorization'].split.last if headers['Authorization'].present?

    nil
  end
end

我们从标准的授权头中获取令牌。然后,我们用上一节的代码来验证它。运行后有两个可能的路径 authorize!:

  • 如果令牌不在那里,或者由于任何原因无效,处理就会在那里停止,调用会返回401
  • 如果令牌是有效的,我们继续执行,并将令牌存储在@token ,这样它就可以使用了。

我们用这个钩子同时保护 protectedadmin 路径,这要感谢before_action

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    before_action :authorize!, except: %i[public]
end

自定义错误信息

知道应用程序不再暴露私人的东西,你不觉得更好吗?我肯定会这样做。但我们必须帮助我们的用户。一个空白的401信息可能无法给他们足够的信息来调试问题。让我们扩展这个钩子,返回一些更明确的信息。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
   def authorize!
     valid, result = verify(raw_token(request.headers))

     # 👇 old code
     # head :unauthorized unless valid
     # 👇 new code
     render json: { message: result }.to_json, status: :unauthorized unless valid

     @token ||= result
   end

现在我们得到一个比没有正文的HTTP代码更有用的信息。

测试受保护的端点

让我们测试一下受保护的端点。

curl localhost:6060/api/messages/protected

该命令将返回401,因为我们没有被授权。我们需要从Auth0获得一个有效的令牌。

最简单的方法是再次访问你的Auth0仪表板的API部分,选择你之前创建的API,然后点击测试标签。在这个部分,你可以通过点击复制令牌图标获得一个临时令牌来测试你的网络API,如下图所示。

Test Token

让我们把令牌附加到请求中。

export TOKEN=the-test-token
curl localhost:6060/api/messages/protected -H "Authorization: Bearer $TOKEN"

请求现在可以工作了!

{"message": "The API successfully validated your access token."}

检查权限

有一个要求有待满足。正如你可能记得的,对于 /api/messages/admin一个有效的token是不够的。相反,我们希望在permissions 要求中有正确的权限。这个额外的检查允许我们为我们的端点定义更精细的访问标准。这个功能在add-rbac分支中实现。

我们在application_controller ,添加一个新的方法,check_permissions 。它接收解码后的令牌,并寻找一个给定的权限。

# app/controllers/application_controller.rb
require 'json_web_token'

class ApplicationController < ActionController::API
  # ... existing code ...
  
  def can_read_admin_messages!
    check_permissions(@token, 'read:admin-messages')
  end
  
  def check_permissions(token, permission)
    permissions = token['permissions'] || []
    permissions = permissions.split if permissions.is_a? String

    unless permissions.include?(permission)
      render json: { message: 'Access is denied' }.to_json,
             status: :unauthorized
    end
  end
  
  # ... existing code ...
end

我们再次使用一个before_action ,以保护admin 端点。

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    before_action :can_read_admin_messages!, only: %i[admin]
    
    # ... existing code ...
end

有了这个,我们的示例应用程序中的每个路由都是安全的,是我们想要的方式。

测试权限

如果你试图调用 /api/messages/admin端点,它将返回401。我们的令牌是有效的,但没有必要的权限。让我们创建一个新的!

回到Auth0仪表板上的API配置页面,选择 "机器对机器 "应用程序标签。你会得到一个应用程序的列表,其中一些名称中带有*(测试应用程序)的标签。当你在那里创建一个API时,这些应用程序是由Auth0自动创建的。它们是作为你的API的测试客户端。其中一个应该有授权的*开关。点击它旁边的下拉菜单,检查 read:admin-messages权限。

Adding Permissions

然后,点击更新按钮,再次移动到测试标签,并复制已经为你生成的新令牌。让我们再试一次。

export TOKEN=the-new-test-token
curl localhost:6060/api/messages/admin -H "Authorization: Bearer $TOKEN"

请求现在可以工作了

{"message":"The API successfully recognized you as an admin."}

总结

在本指南中,我们通过使用Auth0作为授权服务器为Rails API添加了授权。多亏了jwt gem,我们不需要投入太多的精力。我们使用该库来解码API收到的承载令牌。之后,就是在每次请求前检查相关的路由。