OAuth 2.0:用 Go 跟 Google 要資料

上一篇的結論中,我們講到開發者通常最想知道,開發 OAuth 2.0 客戶端需要什麼知識。後端工程師要實現 OAuth 2.0,最常見的情境是開發一個客戶端應用,用來存取資源擁有者的受保護資源。因此在這篇中,我們將用 Go 來牛刀小試一番,開發一個網路應用,它會取得使用者同意後,跟 Google 拿取使用者姓名並顯示出來。

註冊客戶端

不是隨便哪個應用都能跟 Google 授權伺服器申請授權,要跟授權伺服器互動,首先要人家願意信任你。因此,在開始寫程式前,要先到 GCP 的 APIs & Services 中註冊客戶端,連結是這個

點選 CREATE CREDENTIALS 並選擇 OAuth client ID,創造一個新的客戶端憑證

我們要開發的是個網路應用,Application type 選 Web application。Authorized redirect URIs 是授權伺服器同意的轉址位置,想想,如果今天客戶端要轉到哪,授權伺服器就把瀏覽器轉到哪,連惡意網頁也照轉不誤,這聽起來還挺恐怖的,因此 GCP 希望我們先約定好同意的轉址位置,如果位置不對,授權伺服器會回覆錯誤,不再往下進行。

在這裡,我們開發的應用會跑在本機,轉址位置用

http://localhost:8080/callback

點選 CREATE 後,GCP 會建立客戶端憑證,其中的 Client ID 跟 Client Secret 是兌換 Token 時需要帶的資訊

申請授權

可以開始來開發客戶端了。客戶端本質上是個網路應用,用 gin 搭建簡單的 http server,它會提供一個 API,使用者呼叫後,返回使用者的姓名。

func main() {
    e := gin.New()
    e.GET("name", GetName)
    e.Run("localhost:8080")
}

當然我們現在沒有 Token,要不到姓名,得先跟授權伺服器申請授權碼。因此在 API 前面加一層 middleware,如果沒有 Token 的話,在 middleware 會攔下來,改成跟授權伺服器拿授權碼

func main() {
    e := gin.New()
    e.Use(CheckToken)
    e.GET("name", GetName)
    e.Run("localhost:8080")
}

怎麼申請授權碼呢?Google 有 OAuth 2.0 的庫,並內建各大授權伺服器的端點,拿來用就行了

import (
    "context"
    "encoding/json"
    "errors"
    "io/ioutil"
    "net/http"
    
    "github.com/gin-gonic/gin"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

func main() {
    cfg = NewGoogleOAuthConfig()
    e := gin.New()
    e.GET("callback", OAuth2Callback)
    e.Use(CheckToken)
    e.GET("name", GetName)
    e.Run("localhost:8080")
}

func NewGoogleOAuthConfig() *oauth2.Config {
    config := &oauth2.Config{
        ClientID:     "372889357683-xxxxxxxxxx.apps.googleusercontent.com",
        ClientSecret: "GOCSPX-xxxxxxxxxx-fmXr0Dc",
        RedirectURL:  "http://localhost:8080/callback",
        Scopes: []string{
            "https://www.googleapis.com/auth/userinfo.profile",
        },
        Endpoint: google.Endpoint,
    }
    return config
}

func CheckToken(ctx *gin.Context) {
    if token == nil {
        ctx.Redirect(http.StatusFound, cfg.AuthCodeURL("state"))
        ctx.Abort()
    }
}

有讀理論有幫助,放進我們剛剛申請的 ClientID 與 Secret,RedirectURL 也用之前約好的 URL。既然是申請授權,也得讓人知道要授權哪些東西,這裡的 Scopes 可以看 Google OAuth 2.0 文件的說明

對,你沒看錯,範圍有、夠、多,請自行針對應用需求找到你要的範圍,我們要的基本上是 userinfo 的 profile,放入

https://www.googleapis.com/auth/userinfo.profile 

state 是 CSRF Token,記得要用隨機字串,這裡先敷衍過去(資安風險通常是敷衍後忘了改,好孩子不要學)

ctx.Redirect(http.StatusFound, cfg.AuthCodeURL("state"))

做完這些事後,我們可以預期,使用者打 API 後,會跳轉到 Google 的授權頁面,直到客戶端拿到授權碼,實際上也是

授權伺服器會將授權碼發到接收端點,我們也建立一個,方便它把授權碼丟回來

func main() {
    cfg = NewGoogleOAuthConfig()
    e := gin.New()
    e.GET("callback", OAuth2Callback)
    e.Use(CheckToken)
    e.GET("name", GetName)
    e.Run("localhost:8080")
}

func OAuth2Callback(ctx *gin.Context) {
    state := ctx.Query("state")
    if state != "state" {
        ctx.AbortWithError(http.StatusUnauthorized, errors.New("invalid csrf token"))
        return
    }
    code := ctx.Query("code")
    var err error
    token, err = cfg.Exchange(context.Background(), code)
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    client = cfg.Client(context.Background(), token)
    ctx.Redirect(http.StatusFound, "http://localhost:8080/name")
}

收到授權伺服器的回應後,記得先確認 CSRF Token 正不正確,避免有人假冒。接著取出授權碼,用它來交換 Token。

Google 的 OAuth 庫很貼心,只要放上 Token,連 HTTP Client 都幫忙生好了,大致是處理一些 HTTP 的設定,例如像是依照 Token 的類型來設定 Header

// SetAuthHeader sets the Authorization header to r using the access
// token in t.
//
// This method is unnecessary when using Transport or an HTTP Client
// returned by this package.
func (t *Token) SetAuthHeader(r *http.Request) {
    r.Header.Set("Authorization", t.Type()+" "+t.AccessToken)
}

處理完授權流程,繼續原本執行到一半的 GET /name,用 Redirect 重定向回去

ctx.Redirect(http.StatusFound, "http://localhost:8080/name")

存取資源

跟 Google 拿資源的方式跟呼叫普通的 API 沒兩樣,只是你得先知道要呼叫哪個 API,因為 Google 真的太多服務了,你可以用它的 APIs Explorer 來找。

我們需要呼叫的是 People API 中的 people.get。

依照 API 文件,放上對應的 URL

func GetName(ctx *gin.Context) {
    res, err := client.Get("https://people.googleapis.com/v1/people/me?personFields=names")
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    defer res.Body.Close()
    var resp map[string]interface{}
    data, _ := ioutil.ReadAll(res.Body)
    json.Unmarshal(data, &resp)
    ctx.JSON(http.StatusOK, resp["names"].([]interface{})[0])
}

呼叫 API 前,記得到 GCP 的設定上,打開 API,要不然還是不能呼叫,連結在這

跑一次看結果,打開瀏覽器,輸入

http://localhost:8080/name

得到

{
    "displayName": "Ken Chen",
    "displayNameLastFirst": "Chen, Ken",
    "familyName": "Chen",
    "givenName": "Ken",
    "unstructuredName": "Ken Chen"
}

看起來還不錯,有拿到正確的資料。

當然 Google 的 People API 有提供 Go 的 SDK,所以也可以用現成套件,省掉維護的麻煩

go get google.golang.org/api/people/v1

修改原本的 function

func GetName(ctx *gin.Context) {
    people, err := service.People.Get("people/me").PersonFields("names").Do()
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    ctx.JSON(http.StatusOK, people.Names)
}
    
func OAuth2Callback(ctx *gin.Context) {
    state := ctx.Query("state")
    if state != "state" {
        ctx.AbortWithError(http.StatusUnauthorized, errors.New("invalid csrf token"))
        return
    }
    code := ctx.Query("code")
    var err error
    token, err = cfg.Exchange(context.Background(), code)
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    client = cfg.Client(context.Background(), token)
    service, _ = people.NewService(ctx, option.WithTokenSource(cfg.TokenSource(ctx, token)))
    ctx.Redirect(http.StatusFound, "http://localhost:8080/name")
}

風格跟 gRPC 有點像,都是稱為 Service,也是用 functional option 的方式來設定,單純從開發者體驗來看,會覺得風格有些強烈。看 Google API 的 Repository,這些 SDK 都是用 generator 產生的,大概也是因為這樣,把抽象層次都拉得比較高。

小結

有另外兩篇理論打底,這篇實作客戶端就輕鬆多了。像 Google 或 Facebook 這類大型公司都有支援 OAuth 2.0 授權,客戶端只需要呼叫 API 就能完成授權流程,開發體驗算是很完整,也有現成的 SDK 跟 Quick Start 降低開發門檻。真要說問題,大概是要知道去哪裡找開發用的資訊。

以 Google 來說,我們這次開發用到四、五個網頁,大致可分為 GCP、Scope 跟 People API。這是因為他們對到的 OAuth 2.0 角色有些不同,GCP、Scope 偏授權伺服器那端、People API 偏受保護資源那端,如果對流程不熟,可能逛一逛就迷路了,不知道怎麼進行下去;另外是對開發生態系的熟悉度,看得出來 Google API 文件資訊架構有經過設計,能理解背後組織的邏輯,應該能幫助開發者更快定位到資源。

希望看完這篇,能讓讀者對 OAuth 2.0 如何落實到實務有個想像。有時候理論講再多都不如親手操作一次來得有感。

Reference

Read more

Weekly Issue 第 7 期:從 GitHub Spark 看 Prompt 工程

近期開始有人建議用 Context Engineering 來取代 Prompt Engineering,的確相較於 Prompt,Context 是更精確的用詞。前一期也提到,當 Duolingo 的 CEO 被問到 AI 是否只是模型套皮時,他也說模型一定有影響,但更多是關乎你的 Context。 那麼,業界現在是如何看待 Prompt 的呢?Github Spark 跟 V0 的例子或許能提供一些參考。 🗞️ 熱門新聞 Using GitHub Spark to reverse engineer GitHub Spark GitHub Spark 最近推出公開預覽,讓你可以用 prompt 直接開發應用。 作者用逆向工程,找出 Spark 的 system

By Ken Chen

Weekly Issue 第 6 期:Duolingo CEO 看 AI 與遊戲化

現在是 AI 時代,大家都在想怎麼讓自己的產品跟 AI 掛勾,但具體要怎麼做呢?背後的思考有哪些?Duolingo 給出他們自己的觀點。 例如,現在的產品是否只是 AI 套皮,你接收使用者的問題,套上自己的提詞後,拿去給 OpenAI,要它回答你?在現在百家爭鳴的情況下,選擇哪個模型會有差嗎?AI 能帶來新用戶與新營收嗎?等等。 另外本週也選了一篇少數派的文章,談 AI 對 RSS 的影響,對 RSS 未來方向有興趣的人不妨看看。 🗞️ 熱門新聞 Duolingo CEO Luis von Ahn wants you addicted to learning Duolingo CEO 專訪,相當紮實,推薦閱讀。 「對我們來說,

By Ken Chen

Weekly Issue 第 5 期:OpenAI 的企業文化

我一直都喜歡看科技公司的願景與文化,原因是,我想知道別人是如何看待自己的使命,又是用什麼方式打造它。願景通常在官網都會有,但想要知道文化,只能聽內部人講講了。 Palantir 前陣子因為它不同於矽谷的文化,而引起很多討論。受此影響,前 OpenAI 的員工在離職創辦公司後,也發文談論他所見到的 OpenAI。最讓我震撼的是,他們幾乎沒有資金困擾,想的都是如何打造出色的 AI 模型。 🗞️ 熱門新聞 Reflections on OpenAI 前員工談 OpenAI 的內部文化。 讀起來最大的感觸是,有些價值觀、觀點、實踐,只有在世界級的公司跟資源下,才有可能建立起來。讓每個團隊各自為政,看誰能端出最好的成果,這對新創(特別是沒拿創投)實在太奢侈了。 我相信這種經歷會變成是「可以帶著走的饗宴」,那種衝擊也是最寶貴的。 AI Open Source Productivity METR 前陣子發了一篇研究,說使用 AI

By Ken Chen

Weekly Issue 第 4 期:Canonical 的面試經驗

這星期看了比較多職涯相關的內容,最讓我驚訝的是 Canonical 的面試流程,當我分享這則新聞後,有更多朋友紛紛補充他們的面試經驗:需要經歷三個 Tier,每個 Tier 都有三關,而內容甚至還包括問人選「高中成績」與「大學生活」。 我很難想像一家做 Linux 發行版的公司,會如此草率對待人選,這讓我對他們家的產品有了很大的問號。 🗞️ 熱門新聞 My experience with Canonical's interview process 這是一篇 Canonical 的面試經歷(如果你不知道什麼是 Canonical,就是開發 Ubuntu 的公司)。 整個過程讓人非常驚訝,甚至還需要人選回答「高中成績」,而在面試中做筆記居然是扣分項。我看完後有股移除 Ubuntu 的衝動。真的太扯啦。 What happens when engineers work

By Ken Chen