Case Study: 以HackMD為例,使用Signed URL為雲端儲存服務做到authorization機制

Golang架構設計程式語言雜記雲端服務

公司目前的服務有幾隻API會提供用戶上傳檔案(KYC及回傳明細)用。而現有檔案上傳機制是將檔案以BLOB的形式存在資料庫的ATTACHMENT資料表內。

這種做法的好處是上傳檔案並且更改記錄狀態可以包成一包transaction,實現業務邏輯上資料的一致性(要嘛用戶的狀態為等待上傳並且屬於此用戶上傳的檔案還不存在、要嘛用戶的狀態為已上傳並且屬於此用戶上傳的檔案已歸檔)。

但是這樣做的壞處是DB那台機器對於操作下載檔案時會消耗一部分CPU與network io bandwidth,也就等於會拖慢DB處理關聯式資料CRUD的主業。隨著使用量的增加,團隊中最近開始考慮要將檔案上傳的功能拆出至DB以外的solution。

要解決的問題以及HackMD如何解決

一般最普遍的雲端儲存服務(E.g. GCS、S3、阿里雲)在檔案上傳後會產生一組對應的static url,訪問該組url就可以獲取對應的檔案,雖然url很長,但是還是會有被人家亂try的疑慮。況且檔案屬於KYC之類的驗證照片,一定要有某種authorization機制來限制瀏覽者,而不是用賭的賭url不會被猜中。

剛好最近在使用HackMD時,發現他們使用了一招叫signed url的方式,實現他們把附件上傳至Amazon S3的方便性,又同時做到authorization機制讓只有自己上傳的附件才能被自己讀取。於是在研究完後順手寫了這篇case study。

什麼是Signed URL

直接看HackMD使用上的效果。

當我們在HackMD隨便上傳個圖片時,markdown會顯示圖片連結。

跟著連結走,可以看到這個連結返回的是一個302 redirect,而redirect的目的地才是HackMD的S3 bucket的實際pepe🐸圖片位址。

仔細研究一下這個在S3 bucket的圖片url,還多帶了AWSAccessKeyIdExpiresSignature三個參數,而這整串含參數的url就是所謂的signed url。

因為有了這三個參數,讓url多了時效性的限制而不是永久有效可以被存取,這樣url在短時間內幾乎不可能被人家亂try成功訪問到應該是private屬於這個user的圖片的疑慮(以這組url來說效期只有5分鐘),如果還是有leak那最大的機會會是user自己流漏出去的。

https://hackmd-prod-images.s3-ap-northeast-1.amazonaws.com/uploads/upload_ad68b68fd274cd546aec3cc5387302f1.png?
AWSAccessKeyId=AKIA3XSAAW6AWSKNINWO&
Expires=1686500330&
Signature=q4RTSsosiskhgrwo%2BpxMTsmUZLA%3D

了解了signed url後我們回到上一步。

在取得signed url前還記得我們call了看起來是HackMD自己的API:https://hackmd.io/_uploads/BJJePGeD2.png,並且附上了我在HackMD的session id。

其實這隻API就是做authorization用的,只有確認是本人訪問時,HackMD才會當場去跟amazon簽一個新的signed url,再返回302讓我們redirect到S3拿圖片,並且效期維持5分鐘。
(不信可以打看看:https://hackmd.io/_uploads/BJJePGeD2.png)

組合authorization API與返回signed url這兩招,HackMD最大限度做到了只有用戶(resource owner)才能存取自己上傳的檔案,除非用戶在那5分鐘內自己把signed url流出去。

HackMD產生Signed URL的流程

程式碼試玩如何產生Signed URL

HackMD拿Amazon的S3做雲端儲存方案,我們換個雲拿GCP的GCS來試驗看看。

在coding前要先開好service account的權限讓service account有可以操作GCS的storage object權限,因為之後的sign與validation都是基於這隻service account。

上傳資源到雲端儲存服務大家應該都有用過,我們跳過這個part把圖片準備好,直接從sign url開始。假裝我在前面已經打過API在GCS中上傳一張圖片,此時上傳的圖片是not public狀態,不存在可以直接存取這個檔案的static url,如下圖。

接著來寫取得檔案的signed url的程式邏輯。

jsonKey是剛剛那隻service account的金鑰資訊,如果是production ready的code要塞去env內。

以GCP來說會為每個服務或VM創建獨立用途的service account並且盡量限縮service account能使用的權限,這樣萬一不小心data breach了一爆全站就爆了(雞蛋不要放在同個籃子的概念)。

package main

import (
    "log"
    "time"
    "cloud.google.com/go/storage"
    "golang.org/x/oauth2/google"
)

var jsonKey = []byte(`{
    "type": "service_account",
    "project_id": "XXXXXXXXXX",
    "private_key_id": "XXXXXXXXXX",
    "private_key": "-----BEGIN PRIVATE KEY-----\nXXXXXXXXXXXXXXXXXXXX\n----END PRIVATE KEY-----\n",
    "client_email": "XXXXXXXXXX",
    "client_id": "XXXXXXXXXX",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_x509_cert_url": "XXXXXXXXXX",
    "universe_domain": "googleapis.com"
}`)

func main() {
    bucket := "storage.andywu.tw"
    filename := "pepe.png"

    cfg, err := google.JWTConfigFromJSON(jsonKey)
    if err != nil {
        log.Fatalln(err)
    }

    url, err := storage.SignedURL(bucket, filename, &storage.SignedURLOptions{
        GoogleAccessID: cfg.Email,
        PrivateKey:     cfg.PrivateKey,
        Method:         "GET",
        Expires:        time.Now().Add(99 * 365 * 24 * time.Hour),
    })
    if err != nil {
        log.Fatalln(err)
    }

    log.Println(url)
}

執行一下得到output:

zsh > go run main.go

https://storage.andywu.tw/pepe.png?
Expires=4810259975&
GoogleAccessId=gcs-upload%40andywu-webservice.iam.gserviceaccount.com&
Signature=sOzAsEmK5Ro8C%2BW4sjTTC9bM38npc%2BHrj%2F7jpPBL2eJ0e9V3OrBYNI%2BF8%2BKLkVCxgqMffEU7SiLX%2BlETbRwFyDlJhWj6Ss5ZFNyDqRTqpG1sj%2FIfIN3Kxtf6kJ0m1TrA9FM1bzgRIEMMOwxC%2BOYC4jlNX7mFMMPekdsgnwVJoHIXHg6cI3aZ815J7I%2FKCIn9DavEwsPfjaWiRShohhh4O5rnnoWZzR2T5rilIJfKKklXd0LmBf70oPa26dwaKcbHR7sHsyxPWB20UX%2F1wjBI9ox3Zlu%2BIjASr6igdxP9PrkXPSTWSnEAvHAqdwtXDdo54Wv2JVsKg6%2FvutV5ajY49Q%3D%3D

此為上面的URL,expires我故意設99年,可以試試亂換參數都會導致url失效被access denied。

到這邊,我們成功複刻了HackMD產signed url的核心邏輯了~

如果要完整一點只要再搭配短網址對應resource id的權限檢查 + 仿照回傳302的status code以及header塞redirect的location就大功告成了。

結論

透過HackMD的case study,我們學到了如何利用signed url做到享受雲端儲存服務提供的檔案儲存的穩定性,又做到authorization機制管控每個檔案被存取的權限,將兩種優勢結合起來使用。

參考資料

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *