使用 OAuth 为简单的命令行脚本访问 Google 数据

2019 年 1 月 22 日



我需要编写一个简单的脚本从 Google 网站提取一些数据。由于我正在获取一些私人数据,因此我需要授权自己才能执行此操作。我发现这比我预期的要多得多工作,不是因为它很难,而是因为没有太多文档可以指导我 - 我不得不根据大量不相关的文档拼凑出要走的路径。因此,一旦我弄清楚了,我就决定写一篇关于我所做的事情的简短说明,部分是为了以防我需要再次这样做,部分是为了帮助任何想要这样做的人。

我第一次在 2015 年做这件事。大约一年后,它坏了,我没有带宽来修复它。我终于在 2019 年修复了它。虽然我使用的库已经改变了(变得更好),但文档仍然很缺乏,所以我更新了这篇文章。

首先声明一下。这是我弄清楚的,它对我有用,目前为止。我没有对这是否是完成我想要做的事情的最佳方式进行广泛的研究(虽然在我做的时候确实感觉像是广泛的研究)。所以请记住这一点。(如果你有更好的方法,请告诉我。)

我用 Ruby 完成了所有这些,因为这是我熟悉的脚本语言。我还使用了 Google 的 Ruby api 库。但是,大部分整体流程对于其他语言来说都是一样的,所以如果你在 Ruby 之外操作,我认为我所做的大部分内容仍然是相关的。除了 ruby 示例之外,我将尽可能尝试用与语言无关的视图来描述我正在做的事情。

我需要访问 youtube 上的一些私人数据。 [1] 由于它是私人数据,我需要对 Google 进行身份验证并为脚本设置必要的授权,以便它可以访问这些私人数据。我希望在没有任何人工干预的情况下运行此脚本,因此我希望我使用的任何身份验证机制都是脚本可以访问的,至少在我登录笔记本电脑后可以访问。

在我描述我遵循的成功路径之前,我应该提一下我走过的死胡同。使这项简单练习变得如此棘手的一件事是,我阅读的大部分文档都假设我想编写一个指导浏览器的 Web 应用程序。但我想创建一个简单的命令行应用程序(我想是因为我老派了),它不涉及浏览器。我第一次尝试时,我通读了 Google 的身份验证和授权指南 并决定使用 OAuth 2.0,因为这似乎是 Google 想要的方向。然后 Google 给出了 OAuth 授权的几种场景,其中自然(如果复杂)的选择似乎是 服务帐户。这些支持服务器到服务器的访问,身份验证通过公钥/私钥对完成。我花了不少时间摆弄它,最终能够成功地用它访问 Google,然后我遇到了一个障碍。使用服务帐户,你实际上是在 Google 上创建了一个新用户。然后你需要一些机制来允许该用户访问你的个人数据。如果你在 Google 上运行一个域,则有一种方法可以授权服务帐户访问你的域数据。但是,我找不到任何访问我的直接 Google 帐户(如我的帐户)数据的机制。文档暗示你可以对某些属性(如分析)执行此操作,但没有通用机制,例如适用于 youtube 数据的机制。

当我尝试在 2019 年再次尝试时,我再次尝试了服务帐户。这次,它似乎更容易以我想要的方式使用它们。我能够进行一个我认为可以正常工作的调用,但它一直失败。最终,我在文档中找到了这样一行,说服务帐户不适用于 YouTube。在花费数小时来解决问题并遇到这样的硬性障碍时,这总是令人沮丧的,如果这篇文章除了节省一些人的这种努力之外什么都不做,那么它就值得写。

授权流程概述

对我有效的路径是基于 Google 所谓的 用于移动和桌面应用程序的 OAuth 2.0,但我需要对其进行调整以确保我能够(大部分)在没有手动干预或使用浏览器的情况下完成它。

为了更好地解释它是如何工作的,我将从一个简单的请求开始,以获取该 youtube 列表。每当脚本发出请求以获取 Google 数据时,你都需要在你的请求中包含一个访问令牌。Google 的文档显示了这样的 HTTP 请求。

GET /plus/v1/people/me HTTP/1.1
Authorization: Bearer 1/fFBGRNJru1FQd44AzqT3Zg
Host: googleapis.com

访问令牌只是一堆看起来很随机的字符。它持续很短的时间:大约一个小时。访问令牌是脚本完成工作所需的,但这只是引出了一个问题 - 你如何首先获取访问令牌?

获取访问令牌的一种方法是拥有另一种令牌 - 刷新令牌。与访问令牌不同,刷新令牌持续很长时间。它们只有在被撤销、被后来的刷新令牌取代或 Google 发脾气时才会过期。我已经使用同一个令牌访问 Google Analytics 多年了。对于我们脚本的目的,刷新令牌就是工作。一旦我有了刷新令牌,我就可以将其存储在一个安全的地方,脚本可以在没有人工干预的情况下访问该地方。然后,我可以在运行脚本时访问刷新令牌,并作为第一步使用刷新令牌获取一个全新的访问令牌。然后,我将访问令牌用于脚本运行的其余部分(前提是脚本运行时间不超过访问令牌的有效期 - 即使 Ruby 也不那么慢)。

在我解释如何获取刷新令牌之前,还有一件事要说明。每个刷新令牌(以及它们获得的访问令牌)都有一个有限的授权范围 - 意味着你指定它们被允许访问哪些数据。我可以创建一个仅对读取我的 youtube 数据有效的刷新令牌。如果一个坏人获得这个令牌,他将无法读取我的日历数据,也无法修改我的 youtube 数据。拥有具有不同范围的不同令牌有助于我限制我对每个令牌的操作,这让我更安全(并且不那么担心如何安全地存储令牌)。

为了获取刷新令牌,我确实需要让浏览器登录 Google 并以我的身份进行身份验证。像大多数人一样,我的笔记本电脑上永久登录了 Google 的浏览器实例,所以这没什么大不了的。我所做的是访问一个 Google URL,该 URL 的构造方式是为了指定我想要的授权范围。如果我这样做,并且登录了我的 Google 帐户,Google 会给我一个一次性授权码。然后,我将该代码带到另一个 URL,Google 会给我我想要的刷新令牌。这是一个手动步骤,但我只需要很少做一次,所以我对此很满意。

在所有这些之前,我还需要做一件事 - 设置 Google 以使用 API 并允许访问我希望 API 访问的应用程序。这也是一项手动任务,但我只需要做一次(除非 Google 真的非常生气)。

所以,这是我需要执行的步骤

  • 为 API 访问设置 Google - 使用已登录浏览器的单次手动操作
  • 获取一次性授权码 - 需要已登录浏览器,很少执行
  • 用授权码交换刷新令牌 - API,很少执行
  • 使用刷新令牌获取新的访问令牌 - 仅限 API,每次运行脚本时执行一次
  • 在调用 Google 时使用访问令牌 - 仅限 API,每次调用 Google API 时执行

设置 Google

要使用 Google 帐户的 API,我需要进入 Google 并进行设置。我需要去的地方是 Google 开发者控制台。我已经在控制台中定义了一个项目,但如果你还没有,你需要这样做。

我需要做的第一件事是启用 youtube 数据 api,我点击顶部标记为“启用 API 和服务”的链接

点击该链接后,我可以搜索要添加和启用的 API。

接下来,我必须整理凭据,为此我点击“凭据”选项卡(在左侧。如果我还没有凭据,我使用“创建凭据”按钮来创建一些。它让我选择客户端类型:我选择“其他”。然后它会显示一个带有客户端 ID 和客户端密钥的屏幕。我稍后可以通过点击该凭据的铅笔图标来获取这些信息。我很快就会在我的代码中使用它们。

最后,我将适当的 api 范围添加到项目中。为此,我点击顶部标记为“OAuth 同意屏幕”的链接。我向下滚动到“Google API 范围”部分,然后点击“添加范围”按钮以添加 ../auth/youtube.readonly 范围。

获取一次性授权码

为了获取一次性授权码,我需要在登录 Google 的情况下点击一个精心制作的 Google URL。然后 Google 会返回授权码。Google 的文档以及我遇到的各种示例解释了如何通过 Web 应用程序执行此操作。在正常的 Web 应用程序流程中,Web 应用程序意识到它需要身份验证,并将用户发送到 Google。

Google 可以直接将授权码返回给 Web 应用程序。你只需要在本地机器上运行一个服务器并告诉 Google 它的 URL - 例如 localhost:1234。然后 Google 会向该 URL 发出 GET 请求,并将授权码作为 URL 中的参数包含在内。然后你的代码可以轻松地提取参数。你不需要在这个端口上运行太多 Web 服务器来接收它,它只需要响应此单个请求。这种级别的简单服务器甚至不需要 Sinatra(Ruby 的轻量级 Web 服务器框架),我记得多年前在 Prag Dave 的一个 Ruby 入门课程中,我们在几分钟内编写了一个简单的 Web 服务器。但我太懒了,连那都不想做。

我所做的是让我的程序制作必要的 Google URL 并将其打印到控制台上。然后,我将其复制粘贴到我的浏览器中。Google(经过一些舞蹈来检查我知道自己在做什么)会在网页上用授权码进行响应。然后,我将此代码复制粘贴回我的脚本中。它不像自动机制那么流畅,但我并不在乎,因为我只需要每隔一段时间做一次。

让我们看看我的代码。我将任何非平凡的命令行脚本分成多个类,将处理命令行交互的类与执行幕后所有工作的“引擎”类分开 - 本质上是 分离的演示 的使用。我这样做是因为我发现当我在处理它们时,将命令行与核心代码分开更容易。在这种情况下,它几乎不值得,但我发现它是一个有用的习惯。

为了操作凭据,我创建了一个 Google 凭据类

class GoogleCredentials…

  def initialize(application_name: nil, refresh_key: , scopes: nil,
      client_secret: nil, client_id: nil)
    @application_name = application_name
    @refresh_key = refresh_key
    @scopes = scopes
    @client_secret = client_secret
    @client_id = client_id
  end

我可以使用工厂方法创建一个凭据对象,并将我需要的所有数据放入其中

class GoogleCredentials…

  def self.for_youtube
    return self.new(
      application_name: 'Youtube Analytics',
      refresh_key: 'yt-analyze',
      scopes: ['https://www.googleapis.com/auth/youtube.readonly'],
      client_id: '12434.apps.googleusercontent.com',
      client_secret: '1234secretstring'
      )
  end

尽管它的名字是 client_secret,但在这种情况下它并不是什么秘密,更像是一个用户 ID。

大多数数据都需要与 Google 交互。唯一的例外是 refresh_key,这是我用来存储刷新令牌的密钥。

为了获取授权码,我需要创建一个 Google URL 来访问它。我使用 authorization_url 方法来实现。

class GoogleCredentials…

  def authorization_url 
    params = {
      scope: @scopes.join(" "),
      redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
      response_type: 'code',
      client_id: @client_id
    }
    url = {
      host: 'accounts.google.com',
      path: '/o/oauth2/v2/auth',
      query: URI.encode_www_form(params)
    }

    return URI::HTTPS.build(url)
  end

我使用 Thor 库[2] 来处理命令行。

class CLI…

  class CLI < Thor
    include Thor::Actions

    def initialize *args
      super(*args)
      @engine = GoogleCredentials.for_youtube
    end
    
    desc "url", "display the google auth url to hit in the browser"
    def url
      puts @engine.authorization_url
    end      

有了这个设置,我可以在命令行中输入 ruby cli.rb url,我的代码会打印出一个类似这样的 URL。

https://accounts.google.com/o/oauth2/auth?
  scope=https://www.googleapis.com/auth/youtube.readonly&
  redirect_uri=urn:ietf:wg:oauth:2.0:oob&
  response_type=code&
  client_id=12434.apps.googleusercontent.com

为了便于阅读,我添加了换行符和空格,并解码了 URL 转义字符。我还编造了 client_id。

URL 的参数是

  • scope: 我们想要访问的 API 范围,在本例中,我们想要只读访问 YouTube 数据 API。
  • redirect_uri: 在使用 Web 应用程序的通常流程中,Google 会将浏览器重定向到另一个 URL(通常是本地主机 POST),并将响应存储在那里。使用此值告诉 Google 我希望它显示在浏览器中,以便我可以复制和粘贴。
  • response_type: 我想要一个一次性授权码。
  • client_id 我从之前与 Google 开发者控制台的交互中获得此值。

将该 URL 粘贴到我的浏览器中最终会将我带到 Google 的一个网页,该网页会显示闪闪发光的授权码。

用授权码交换刷新令牌

现在我有了授权码,我可以启动第二个操作,获取刷新令牌。我通过再次联系 Google 授权资源来实现,这次提供我刚刚从他们那里获得的授权码,并将其与我的 client-secret(一个标识我与 Google API 的代码)混合在一起。我不需要为此步骤登录 Google,也不需要使用浏览器。

在这一点上,我必须面对另一个问题:我应该在哪里存储刷新令牌?由于这是一个只有我使用的脚本,我可以将其存储在源代码中,例如

def refresh_token
  '1234567890WOxNS_gTztCGW3OBTKcSoKfLXDPc5TA7xz4MEudVrK5jSpoR30zcRFq6'
end

我不喜欢这样做,因为我喜欢将我的代码保存在广泛复制且经常与他人共享的存储库中。事实上,一般的安全建议是 **永远不要将秘密存储在您的存储库代码树中的任何地方**。意外提交包含秘密的文件太容易了,一旦完成,几乎不可能删除。由于我天生比较粗心,我试图安排一些事情,以便我不可避免的错误不会造成持久性损害。

另一个选择是将令牌直接转储到源代码树之外的文件中。我的硬盘驱动器已加密,因此这相当安全 - 特别是由于我保护的只是我的 YouTube 观看习惯的黑暗秘密。如果我更偏执一点,我可以加密该文件,但这只会引发一个问题:在哪里存储文件的加密密钥,因为我不想每次使用脚本时都输入密码。

由于我在 Mac 上运行它,我决定使用 Mac 的内置钥匙串。它会在我登录时自动打开,我可以使用 security 命令行应用程序访问它。如果我想在我的 Ubuntu 机器上运行它,我将不得不考虑其他方法,但我将在需要的时候处理它。

为了获取刷新令牌,我需要使用之前获得的一次性授权码来请求新令牌,找出刷新令牌,并将其放入我的钥匙串中。(我说“令牌”,因为 Google 会同时返回访问令牌和刷新令牌。)

为了请求这些令牌,我再次与 Google 交谈,但这次我发现最好使用 Google API 的 Ruby 客户端库。以下是获取令牌的代码。

class CLI…

  desc "refresh", "put in auth code, save refresh code"
  def refresh
    auth_code = ask "paste in the authorization code"
    @engine.renew_refresh_token auth_code
  end

class GoogleCredentials…

  def renew_refresh_token auth_code
    token = get_new_refresh_token(auth_code)
    puts "new token: #{token}"
    save_refresh_token token
  end

  def get_new_refresh_token auth_code
    client = Signet::OAuth2::Client.new(
      token_credential_uri: 'https://www.googleapis.com/oauth2/v3/token',
      code: auth_code,
      client_id: @client_id,
      client_secret: client_secret,
      redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
      grant_type: 'authorization_code'
      )
    client.fetch_access_token!
    return client.refresh_token
  end

这段代码首先使用所有必要的数据实例化一个 Signet OAuth2 客户端 对象,然后告诉它获取访问令牌。完成之后,我可以向它请求刷新令牌并将其保存起来。

我将令牌保存到我的 Mac 的钥匙串中。

class GoogleCredentials…

  def save_refresh_token arg
    cmd = "security add-generic-password -a '#{@refresh_key}' -s '#{@refresh_key}' -w '#{arg}'"
    system cmd
  end

security 命令在存储值时需要服务 (-s) 和帐户 (-a)。我为它们使用相同的值,因为我只想要一个键值存储。

使用刷新令牌获取访问令牌

上面的授权逻辑很少见,我预计每隔一段时间才会调用它一次,事实上,在过去几年中我只运行过两次。希望在下一次需要它之前,库不会发生变化,因此我不需要再次修改它。如果我需要访问新的范围,我将声明一个新的工厂方法。

现在我有了凭据对象,我只需要使用它来做一些有用的事情(或者在本例中打印我的播放列表)。

为了使用刷新令牌,我需要使用刷新令牌创建一个 UserRefreshCredentials,并使用 fetch_access_token! 让它与 Google 交谈并加载我需要调用 Google API 的访问令牌。以下是代码。

class GoogleCredentials…

  def load_user_refresh_credentials
    @credentials = Google::Auth::UserRefreshCredentials.new(
      client_id: @client_id,
      scope: @scopes,
      client_secret: @client_secret,
      refresh_token: refresh_token,
      additional_parameters: { "access_type" => "offline" })
    @credentials.fetch_access_token!
    return @credentials
  end
  def refresh_token
    @refresh_token ||= `security find-generic-password -wa #{@refresh_key}`.chomp
    @refresh_token
  end

从 Google API 获取视频列表

当我第一次写这篇文章时,访问 Google 的 Ruby 库特别不透明。它们使用运行时代码生成,因此我需要使用 pry 才能弄清楚我可以调用哪些方法。但现在它们在构建过程中执行代码生成,并将生成的类作为一等公民的工件存储。这使我能够看到它们有哪些方法,这使得与它们一起工作变得容易得多。这也使它们能够在 rubydoc 上在线提供 API 文档。

为了与 YouTube 交谈,我需要使用 YouTube 服务。为了解决授权和身份验证问题,我只需向它提供用户刷新凭据。

auth_client = GoogleCredentials.for_youtube.load_user_refresh_credentials
youtube = Google::Apis::YoutubeV3::YouTubeService.new
youtube.authorization = auth_client

我现在可以调用此 YouTube 对象上的方法,例如列出播放列表中的项目的方法。

youtube.list_playlists('snippet', max_results: 50, mine: true)

该调用返回一个 ListPlaylistReponse 对象。这是一个简单的数据对象,是那些我通常鄙视的贫血数据对象之一,但在这种情况下非常有用,因为它充当 数据传输对象


脚注

1: 这并不完全是我想要做的,但由于我专注于问题的 OAuth 部分,我已经尽可能地简化了实际任务。

2: Ruby 中有很多命令行工具包。我没有对它们进行过全面调查,但 Thor 似乎相当适合我的需求。它做了很多我不关心的东西,但它将这些复杂性隐藏起来,以便我可以轻松地使用它。

重大修订

2019 年 1 月 22 日: 更新文章以适应当前库。

2016 年 2 月 27 日: 由于库的更改,文章已弃用。

2015 年 1 月 26 日: 首次发布。