使用 OAuth 讓簡單的命令列腳本存取 Google 資料
2019 年 1 月 22 日
我需要撰寫一個簡單的腳本,從 Google 網站擷取一些資料。由於我擷取的是一些私人資料,因此我需要授權自己才能執行此動作。我發現這項工作比我預期的還要困難,並不是因為它很難,而是因為沒有太多文件可以指導我 - 我必須根據許多不特別相關的文件拼湊出要走的路徑。因此,在我弄清楚之後,我決定撰寫一份關於我所做工作的簡短說明,部分原因是我可能需要再次執行此動作,部分原因是為了幫助任何其他想要執行此動作的人。
我第一次執行此動作是在 2015 年。大約一年後,它中斷了,而我沒有時間修復它。我最終在 2019 年修復了它。雖然我使用的程式庫已經改變(變得更好),但文件仍然相當不足,所以我更新了這篇文章。
首先聲明一下。這是我的發現,它對我來說有效,目前為止。我沒有深入研究這是否是我執行所需工作方式的最佳方式(雖然在我執行的過程中,這肯定感覺像是深入的研究)。因此請記住這一點。(如果你有更好的方法,請告訴我。)
我使用 Ruby 執行所有這些動作,因為那是我的熟悉腳本語言。我也使用了 Google 的 Ruby API 程式庫。但是,整體流程的大部分對其他語言來說都是相同的,因此如果你在 Ruby 以外作業,我想我所做的許多事情仍然會相關。除了 Ruby 範例之外,我也會盡可能用語言獨立的觀點來描述我在做什麼。
我需要存取 YouTube 上的一些私人資料。[1]由於是私人資料,我需要向 Google 驗證身分,並設定腳本所需的授權,才能取得該私人資料。我想要在不需手動介入的情況下執行這個腳本,因此我想要使用的任何驗證機制,都必須是腳本本身可以存取的,至少在我登入我的筆電後。
在我說明我所遵循的成功路徑之前,我應該先提一下我走過的一條死路。讓這個簡單的練習變得如此棘手的其中一件事,在於我讀過的大部分文件都假設我想撰寫一個引導瀏覽器的網路應用程式。但我想要一個簡單的命令列應用程式(我想是因為我比較老派),不涉及瀏覽器。我第一次嘗試時,我讀了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。如果我在登入我的 Google 帳戶時執行此動作,Google 會給我一個一次性授權碼。然後,我取得該碼並前往另一個 URL,Google 會交給我想要的更新權杖。這是手動步驟,但我很少必須這麼做,所以我沒問題。
在所有這些之前,我需要再做一件事 - 設定 Google 以使用 API 並允許存取我想讓 API 存取的應用程式。這也是手動工作,但我只需要做一次(除非 Google 真的發生很大的歇斯底里)。
以下是我需要執行的步驟
- 設定 Google 以存取 API - 已登入瀏覽器的一次性手動動作
- 取得一次性授權碼 - 需要已登入瀏覽器,很少執行
- 將授權碼兌換為更新權杖 - API,很少執行
- 使用更新權杖取得新的存取權杖 - 僅限 API,每次執行腳本時執行一次
- 呼叫 Google 時使用存取權杖 - 僅限 API,每次呼叫 Google API 時執行
設定 Google
若要使用 Google 帳戶的 API,我需要進入 Google 並設定。我需要的地方是 Google Developers Console。我在主控台中已定義一個專案,但如果你還沒有專案,則需要執行此動作。
我需要做的第一件事是啟用 YouTube 資料 API,我按一下標示為「啟用 API 和服務」的頂端連結

追蹤連結後,我可以搜尋 API 以新增並啟用它們。
接下來,我必須整理認證資料,為此,我按一下左側的「認證資料」標籤。如果我還沒有認證資料,我會使用「建立認證資料」按鈕建立一些認證資料。它會提供客戶端類型的選項:我選擇「其他」。然後,它會顯示一個包含客戶端 ID 和客戶端密碼的畫面。稍後,我可以按一下該認證資料的鉛筆圖示取得這些資訊。我很快就會在程式碼中使用這些資訊。

最後,我將適當的 API 範圍新增到專案。為此,我按一下標籤為「OAuth 同意畫面」的頂端連結。我向下捲動到「Google API 的範圍」區段,然後按一下「新增範圍」按鈕以新增 ../auth/youtube.readonly
範圍。

取得一次性授權碼
若要取得一次性授權碼,我需要在登入 Google 時按一下特別製作的 Google URL。然後,Google 會傳回授權碼。Google 的文件和我在網路上看到的各種範例說明如何透過網路應用程式執行此動作。在正常的網路應用程式流程中,網路應用程式會發現它需要授權,然後將使用者傳送到 Google。
Google 可以直接將授權碼傳回網路應用程式。你只需要在你的本機執行伺服器,並告訴 Google 其 URL - 例如 localhost:1234
。然後,Google 會對該 URL 發出 GET,並將授權碼包含在 URL 的參數中。然後,你的程式碼可以輕易地挑選出參數。你不需要在此埠上使用大量的網路伺服器來挑選出參數,它只需要回應此一要求即可。這種層級的簡單伺服器甚至不需要 Sinatra (Ruby 的輕量級網路伺服器架構),我記得多年前與 Prag Dave 一起參加 Ruby 入門課程,我們在幾分鐘內就寫了一個簡單的網路伺服器。但我懶得執行此動作。
我所做的就是讓我的程式建立必要的 Google URL,並將此 URL 列印在主控台上。然後我將它複製並貼到我的瀏覽器中。Google(經過一些檢查我是否知道我在做什麼的動作)會在網頁上回應授權碼。然後我將此碼複製並貼回我的指令碼中。這不像自動化機制那麼順暢,但我不在乎,因為我只需要每隔一段時間才做一次。
讓我們看看我的程式碼。我將任何非平凡的命令列指令碼分成多個類別,將處理命令列互動的類別與執行所有幕後工作的「引擎」類別分開,這基本上是使用分離呈現。我這樣做是因為我在處理這些類別時,發現將命令列與核心程式碼分開比較容易。在這個案例中幾乎不值得這樣做,但我覺得這是一個有用的習慣。
要操作憑證,我建立一個 Google 憑證類別
類別 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
我可以使用工廠方法建立一個憑證物件,放入我需要的所有資料
類別 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
方法執行此動作
類別 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]來處理命令列
類別 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://127.0.0.1/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 的參數為
- 範圍:我們想要存取多少 API,在這個案例中,我們想要對 YouTube 資料 API 進行唯讀存取
- 重新導向 URI:在與網路應用程式一起使用此功能的通常流程中,Google 會將瀏覽器重新導向到另一個 URL(通常是本機端 post),並將其回應存放在那裡。使用這個值告訴 Google 我希望它顯示在瀏覽器中,以便我複製並貼上
- 回應類型:我想要一個一次性的授權碼
- client_id我從先前與 Google Developers Console 的互動中取得這個
將該 URL 貼到我的瀏覽器中(最終)會引導我到 Google 的網頁,顯示閃亮的授權碼。
將授權碼交換為更新權杖
現在我有了授權碼,我可以啟動第二個操作,取得更新權杖。我透過再次連絡 Google 授權資源來執行此操作,這次提供我剛從他們那裡取得的授權碼,並將其與我的客戶端密碼混合,這是一個用於識別我身分給 google API 的代碼。我無需為此步驟登入 Google,也不需要使用瀏覽器。
在這個時候,我必須面對另一個問題:取得更新權杖後,我將它儲存在哪裡?由於這是一個只有我使用的腳本,我可以將它儲存在原始碼中,類似於
def refresh_token '1234567890WOxNS_gTztCGW3OBTKcSoKfLXDPc5TA7xz4MEudVrK5jSpoR30zcRFq6' end
我不喜歡這樣,因為我喜歡將我的代碼保存在廣泛複製且經常與他人分享的儲存庫中。確實,一般的安全建議是絕不要將機密保存在儲存庫代碼樹中的任何地方。意外提交包含機密的檔案很容易,而且一旦完成,幾乎不可能移除。由於我天生比較粗心,我試著安排事情,讓我的不可避免的錯誤不會造成永久性的損害
另一個選項是將權杖傾倒在原始碼樹外的檔案中。我的硬碟已加密,所以這相當安全 - 特別是因為我所保護的只是我 Youtube 觀看習慣的黑暗秘密。如果我有點偏執,我可以加密該檔案,但這只會引發另一個問題,即檔案的加密金鑰要儲存在哪裡,因為我不想每次使用腳本時都輸入密碼。
由於我在 Mac 上執行此操作,所以我決定使用 Mac 內建的鑰匙圈。這會在我登入時自動開啟,我可以使用security
命令列應用程式存取它。如果我想在我的 Ubuntu 方塊上執行此操作,我必須想出其他方法,但如果有一天我需要這樣做,我會處理的。
若要取得更新權杖,我需要使用我先前取得的一次性授權碼來要求新的權杖,找出更新權杖,並將其放入我的鑰匙圈。(我說「權杖」,因為 Google 會回應存取權杖和更新權杖。)
若要要求這些權杖,我再次與 Google 對話,但這次我發現最好使用Google api 的 ruby 客戶端程式庫。以下是取得權杖的代碼
類別 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
類別 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 委派人物件,其中包含所有需要的資料,然後告訴它擷取 access_tokens。一旦完成後,我可以要求它提供更新令牌並將其儲存起來。
我將令牌儲存在我的 Mac 鑰匙圈中。
類別 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 的存取令牌。以下是該程式碼。
類別 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 日:首次發布