# 使用 OAuth 应用对 REST API 进行身份验证

通过一些示例了解几种不同的身份验证方法。

在本节中，我们将重点介绍身份验证的基础知识。 具体而言，我们将（使用 [Sinatra](http://www.sinatrarb.com/)）创建 Ruby 服务器，该服务器以多种不同的方式实现应用程序的 [Web 流](/zh/enterprise-cloud@latest/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)。

> \[!TIP]
> 可以在 [platform-samples 存储库](https://github.com/github/platform-samples/tree/master/api/)中下载此项目的完整源代码。

## 注册应用程序

首先需要[注册应用程序](https://github.com/settings/applications/new)。 每个已注册的 OAuth app 都会被分配一组唯一的客户端 ID 和客户端密钥。
客户端密码用于获取已登录用户的访问令牌。 必须在本机应用程序中包括客户端密码，但 Web 应用程序不应泄漏此值。

可以随意填写所有其他信息，但**授权回调 URL**除外。 这是安全设置应用程序中最重要的部分。 这是回调网址 GitHub
在身份验证成功后将用户返回到… 该 URL 的所有权可确保用户登录到你的应用，而不是将令牌泄露给攻击者。

由于我们运行的是常规 Sinatra 服务器，因此本地实例的位置设置为 `http://127.0.0.1:4567`。 让我们将回叫 URL 填写为 `http://127.0.0.1:4567/callback`。

## 接受用户授权

> \[!WARNING]
> \*\*
> 退休 注意：\*\* 使用查询参数进行 API 身份验证已经不可用。 应使用 [HTTP 基本身份验证](/zh/enterprise-cloud@latest/rest/authentication/authenticating-to-the-rest-api#using-basic-authentication)对 API 进行身份验证。 有关详细信息，包括计划限电，请参阅[博客文章](https://developer.github.com/changes/2020-02-10-deprecating-auth-through-query-param/)。

现在让我们开始完善我们简单的服务器。 创建名为 server.rb 的文件，并将其粘贴到其中：

```ruby
require 'sinatra'
require 'rest-client'
require 'json'

CLIENT_ID = ENV['GH_BASIC_CLIENT_ID']
CLIENT_SECRET = ENV['GH_BASIC_SECRET_ID']

get '/' do
  erb :index, :locals => {:client_id => CLIENT_ID}
end
```

客户端 ID 和客户端密码来自[应用程序的配置页](https://github.com/settings/developers)。 建议将这些值存储为[环境变量](http://en.wikipedia.org/wiki/Environment_variable#Getting_and_setting_environment_variables)，以方便替换和使用，这正是我们在这里采用的做法。

接下来，在 views/index.erb 中粘贴此内容：

```html
<html>
  <head>
  </head>
  <body>
    <p>
      Well, hello there!
    </p>
    <p>
      We're going to now talk to the GitHub API. Ready?
      <a href="https://github.com/login/oauth/authorize?scope=user:email+offline_access&client_id=<%= client_id %>">Click here</a> to begin!
    </p>
    <p>
      If that link doesn't work, remember to provide your own <a href="/apps/building-oauth-apps/authorizing-oauth-apps/">Client ID</a>!
    </p>
  </body>
</html>
```

（如果你不熟悉 Sinatra 的工作原理，建议你阅读 [Sinatra 指南](https://github.com/sinatra/sinatra-book/blob/main/book/Introduction.markdown#hello-world-application)。）

另请注意，URL 使用 `scope` 查询参数来定义应用程序请求的[范围](/zh/enterprise-cloud@latest/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)。 对于我们的应用程序，我们请求 `user:email` 权限以读取私人电子邮件地址。 我们还将请求 `offline_access` 范围，以获取会过期的令牌，这对安全性更有利。

将浏览器导航到 `http://127.0.0.1:4567`。 单击链接后，应会访问 GitHub并显示“授权应用程序”对话框。

如果你相信自己，请单击“授权应用”。 哇！ Sinatra 显示 `404` 错误。 是什么原因呢？

还记得我们指定了 Callback URL 为 `callback` 吗？ 我们没有为它提供路由，因此 GitHub 不知道在用户授权该应用后将用户重定向到哪里。 现在我们来解决这个问题！

### 提供回调

在 server.rb 中，添加一个路由以指定回叫应执行的操作：

```ruby
get '/callback' do
  # get temporary GitHub code...
  session_code = request.env['rack.request.query_hash']['code']

  # ... and POST it back to GitHub
  result = RestClient.post('https://github.com/login/oauth/access_token',
                          {:client_id => CLIENT_ID,
                           :client_secret => CLIENT_SECRET,
                           :code => session_code},
                           :accept => :json)

  # extract the token and granted scopes
  access_token = JSON.parse(result)['access_token']
end
```

成功应用身份验证后， GitHub 提供临时 `code` 值。
您需要 `POST` 将此代码连同您的客户端密钥一起发送回 GitHub，以换取 `access_token`。
为了简化 GET 和 POST HTTP 请求，我们将使用 [rest-client](https://github.com/archiloque/rest-client)。
请注意，您可能永远不会通过 REST 访问 API。 对于更重要的应用程序，可能应该使用[采用所选语言编写的库](/zh/enterprise-cloud@latest/rest/using-the-rest-api/libraries-for-the-rest-api)。

### 检查授予的作用域

用户可以通过直接更改 URL 来编辑您请求的范围。 这可以授予您的应用程序比您最初请求的更少的访问权限。 因此，在使用令牌发出任何请求之前，您应该检查用户为令牌授予的作用域。 有关请求的范围和已授予的范围的详细信息，请参阅“[OAuth 应用的范围](/zh/enterprise-cloud@latest/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#requested-scopes-and-granted-scopes)”。

已授予的范围作为交换令牌的响应的一部分返回。

```ruby
get '/callback' do
  # ...
  # Get the access_token using the code sample above
  # ...

  # check if we were granted user:email scope
  scopes = JSON.parse(result)['scope'].split(',')
  has_user_email_scope = scopes.include? 'user:email' || scopes.include? 'user'
end
```

在我们的应用程序中，我们使用 `scopes.include?` 来检查我们是否被授予了获取经过身份验证的用户的私人电子邮件地址所需的 `user:email` 范围。 如果应用程序请求了其他范围，我们也会进行相应检查。

此外，由于范围之间存在分层关系，因此你应该检查你是否被授予了任何更高层级的必需范围。 例如，如果应用程序请求 `user` 范围，则不会被显式授予 `user:email` 范围。 在这种情况下，它将收到一个范围为 `user` 的令牌，该令牌可用于请求用户的电子邮件地址，即使它未在令牌上显式包括 `user:email`。 通过检查`user`和`user:email`，可以确保对这两种情况都进行了验证。

仅在发出请求之前检查范围是不够的，因为用户可能会在检查与实际请求之间的时间段更改范围。
如果发生这种情况，预期成功的 API 调用可能会失败，并显示 `404` 或 `401` 状态，或者返回不同的信息子集。

为了帮助你正常处理这些情况，对于使用有效 OAuth 应用令牌发出的请求，所有 API 响应还包含一个 [`X-OAuth-Scopes` 标头](/zh/enterprise-cloud@latest/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)。
该标头包含用于请求的令牌的作用域列表。 此外，REST API 还提供终结点，来[检查令牌的有效性](/zh/enterprise-cloud@latest/rest/apps/oauth-applications#check-a-token)。
使用此信息来检测令牌范围的更改，并将可用的应用程序功能的更改告知用户。

### 发出经过身份验证的请求

最后，使用此访问令牌，你将能够将在用户登录时发出经过身份验证的请求：

```ruby
# fetch user information
auth_result = JSON.parse(RestClient.get('https://api.github.com/user',
                                        {:params => {:access_token => access_token}}))

# if the user authorized it, fetch private emails
if has_user_email_scope
  auth_result['private_emails'] =
    JSON.parse(RestClient.get('https://api.github.com/user/emails',
                              {:params => {:access_token => access_token}}))
end

erb :basic, :locals => auth_result
```

我们可以用我们的结果做任何我们想做的事。 在这种情况下，我们只需将它们直接导入到 basic.erb 中：

```html
<p>Hello, <%= login %>!</p>
<p>
  <% if !email.nil? && !email.empty? %> It looks like your public email address is <%= email %>.
  <% else %> It looks like you don't have a public email. That's cool.
  <% end %>
</p>
<p>
  <% if defined? private_emails %>
  With your permission, we were also able to dig up your private email addresses:
  <%= private_emails.map{ |private_email_address| private_email_address["email"] }.join(', ') %>
  <% else %>
  Also, you're a bit secretive about your private email addresses.
  <% end %>
</p>
```

## 实现“持久”身份验证

如果我们要求用户每次访问网页时都必须登录应用，这将是一个非常糟糕的模式。 例如，尝试直接导航到 `http://127.0.0.1:4567/basic`。 您会遇到一个错误。

如果我们可以绕过整个“单击此处”的流程，只\_记住\_：只要用户已登录GitHub，他们就应该能够访问这个应用程序，会怎么样？ 抓稳了，因为\_这正是我们接下来要做的事情\_。

我们的上述小服务器相当简单。 为了加入一些智能身份验证功能，我们将切换到使用会话来存储令牌。
这将使身份验证对用户透明化。

此外，由于我们在会话中保留令牌，因此在用户检查范围后更新范围、撤销令牌或令牌过期时，需要处理这些情况。 为此，我们将使用 `rescue` 块，并检查第一个 API 调用是否成功，以验证令牌是否仍然有效。 之后，我们将检查 `X-OAuth-Scopes` 响应头，以验证用户是否尚未撤销 `user:email` 范围。

如果将应用配置为请求生存期较短的令牌，则访问令牌也会过期。
交换临时`code`时，响应中还可以包含一个`access_token`以及`refresh_token`。 我们也会在会话中保留刷新令牌，如果 API 调用失败，因为访问令牌已过期或已吊销，我们将使用刷新令牌请求新的访问令牌并重试请求。 仅当刷新失败时，我们才会再次启动 OAuth 流。

创建名为 advanced\_server.rb 的文件，并将以下行粘贴到其中：

```ruby
require 'sinatra'
require 'rest_client'
require 'json'

# Don't use hard-coded values in your app
# Instead, set and test environment variables, like below
# if ENV['GITHUB_CLIENT_ID'] && ENV['GITHUB_CLIENT_SECRET']
#  CLIENT_ID        = ENV['GITHUB_CLIENT_ID']
#  CLIENT_SECRET    = ENV['GITHUB_CLIENT_SECRET']
# end

CLIENT_ID = ENV['GH_BASIC_CLIENT_ID']
CLIENT_SECRET = ENV['GH_BASIC_SECRET_ID']

use Rack::Session::Pool, :cookie_only => false

def authenticated?
  session[:access_token]
end

def authenticate!
  erb :index, :locals => {:client_id => CLIENT_ID}
end

def refresh_access_token!
  # exchange the stored refresh token for a new access token
  result = RestClient.post('https://github.com/login/oauth/access_token',
                          {:client_id => CLIENT_ID,
                           :client_secret => CLIENT_SECRET,
                           :grant_type => 'refresh_token',
                           :refresh_token => session[:refresh_token]},
                           :accept => :json)

  parsed_result = JSON.parse(result)
  new_access_token = parsed_result['access_token']

  # if we didn't get a new access token back, the refresh failed
  return false unless new_access_token

  # store the new access token and refresh token in the session
  session[:access_token] = new_access_token
  session[:refresh_token] = parsed_result['refresh_token']
  true
rescue
  false
end

get '/' do
  if !authenticated?
    authenticate!
  else
    scopes = []
    refreshed = false

    begin
      access_token = session[:access_token]
      auth_result = RestClient.get('https://api.github.com/user',
                                   {:params => {:access_token => access_token},
                                    :accept => :json})
    rescue => e
      # the request didn't succeed because the token was revoked or has
      # expired. If we haven't already tried and we have a refresh token,
      # get a new access token and retry the request once
      if !refreshed && session[:refresh_token] && refresh_access_token!
        refreshed = true
        retry
      end

      # we couldn't refresh the token, so we invalidate the tokens stored in
      # the session and render the index page so that the user can start the
      # OAuth flow again
      session[:access_token] = nil
      session[:refresh_token] = nil
      return authenticate!
    end

    # the request succeeded, so we check the list of current scopes
    if auth_result.headers.include? :x_oauth_scopes
      scopes = auth_result.headers[:x_oauth_scopes].split(', ')
    end

    auth_result = JSON.parse(auth_result)

    if scopes.include? 'user:email'
      auth_result['private_emails'] =
        JSON.parse(RestClient.get('https://api.github.com/user/emails',
                       {:params => {:access_token => access_token},
                        :accept => :json}))
    end

    erb :advanced, :locals => auth_result
  end
end

get '/callback' do
  session_code = request.env['rack.request.query_hash']['code']

  result = RestClient.post('https://github.com/login/oauth/access_token',
                          {:client_id => CLIENT_ID,
                           :client_secret => CLIENT_SECRET,
                           :code => session_code},
                           :accept => :json)

  parsed_result = JSON.parse(result)
  session[:access_token] = parsed_result['access_token']
  session[:refresh_token] = parsed_result['refresh_token']

  redirect '/'
end
```

许多代码应该看起来很熟悉。 例如，我们仍然使用 `RestClient.get` 来调用 GitHub API，并且仍然将结果传递给 ERB 模板进行渲染（这次它叫作 `advanced.erb`）。

此外，我们现在有 `authenticated?` 方法，用于检查用户是否已通过身份验证。 如果未通过，则调用 `authenticate!` 方法，该方法执行 OAuth 流程，并使用授予的令牌及其权限范围更新会话信息。

`refresh_access_token!` 方法会用我们保存在会话中的 `refresh_token` 换取一个新的 `access_token`。 当 API 调用在 `rescue` 块中失败时，我们将调用此方法，并使用 Ruby 的 `retry` 关键字通过新令牌再次运行请求。 如果刷新失败，因为刷新令牌也已过期或令牌被吊销，我们将清除会话，并通过 OAuth 流将用户发送回。 为了防止无限身份验证循环，我们还记得我们刷新了令牌，因此，如果 API 再次失败，可能是因为用户失去了对资源的访问权限。 如果确实如此，无论刷新多少次令牌，都无法让 API 调用恢复正常。

接下来，在视图中创建一个名为 advanced.erb 的文件，并将此标记粘贴到其中 ：

```html
<html>
  <head>
  </head>
  <body>
    <p>Well, well, well, <%= login %>!</p>
    <p>
      <% if !email.empty? %> It looks like your public email address is <%= email %>.
      <% else %> It looks like you don't have a public email. That's cool.
      <% end %>
    </p>
    <p>
      <% if defined? private_emails %>
      With your permission, we were also able to dig up your private email addresses:
      <%= private_emails.map{ |private_email_address| private_email_address["email"] }.join(', ') %>
      <% else %>
      Also, you're a bit secretive about your private email addresses.
      <% end %>
    </p>
  </body>
</html>
```

从命令行中调用 `ruby advanced_server.rb`，这会在端口 `4567` 上启动你的服务器，该端口与我们使用简单 Sinatra 应用时使用的相同。
当您导航到 `http://127.0.0.1:4567` 时，应用会调用 `authenticate!`，将您重定向到 `/callback`。
`/callback` 随后将我们重定向回 `/`，由于我们已经完成身份验证，因此会渲染 *advanced.erb*。

我们只需将回调 URL 从 GitHub 改为 `/`，就可以完全简化这种往返路由。 但是，由于 *server.rb* 和 *advanced.rb* 都依赖同一个回调 URL，因此需要做一点小小的“非常规处理”才能让它正常工作。

此外，如果我们从未授权此应用程序访问我们的 GitHub 数据，我们会看到前面弹出的相同确认对话框，并警告我们。