構建一個即時訊息應用(二):OAuth

Nicolás Parada發表於2019-10-28

上一篇:模式

在這篇帖子中,我們將會通過為應用新增社交登入功能進入後端開發。

社交登入的工作方式十分簡單:使用者點選連結,然後重定向到 GitHub 授權頁面。當使用者授予我們對他的個人資訊的訪問許可權之後,就會重定向回登入頁面。下一次嘗試登入時,系統將不會再次請求授權,也就是說,我們的應用已經記住了這個使用者。這使得整個登入流程看起來就和你用滑鼠單擊一樣快。

如果進一步考慮其內部實現的話,過程就會變得複雜起來。首先,我們需要註冊一個新的 GitHub OAuth 應用

這一步中,比較重要的是回撥 URL。我們將它設定為 http://localhost:3000/api/oauth/github/callback。這是因為,在開發過程中,我們總是在本地主機上工作。一旦你要將應用交付生產,請使用正確的回撥 URL 註冊一個新的應用。

註冊以後,你將會收到“客戶端 id”和“安全金鑰”。安全起見,請不要與任何人分享他們 ?

順便讓我們開始寫一些程式碼吧。現在,建立一個 main.go 檔案:

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"

    "github.com/gorilla/securecookie"
    "github.com/joho/godotenv"
    "github.com/knq/jwt"
    _ "github.com/lib/pq"
    "github.com/matryer/way"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/github"
)

var origin *url.URL
var db *sql.DB
var githubOAuthConfig *oauth2.Config
var cookieSigner *securecookie.SecureCookie
var jwtSigner jwt.Signer

func main() {
    godotenv.Load()

    port := intEnv("PORT", 3000)
    originString := env("ORIGIN", fmt.Sprintf("http://localhost:%d/", port))
    databaseURL := env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/messenger?sslmode=disable")
    githubClientID := os.Getenv("GITHUB_CLIENT_ID")
    githubClientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
    hashKey := env("HASH_KEY", "secret")
    jwtKey := env("JWT_KEY", "secret")

    var err error
    if origin, err = url.Parse(originString); err != nil || !origin.IsAbs() {
        log.Fatal("invalid origin")
        return
    }

    if i, err := strconv.Atoi(origin.Port()); err == nil {
        port = i
    }

    if githubClientID == "" || githubClientSecret == "" {
        log.Fatalf("remember to set both $GITHUB_CLIENT_ID and $GITHUB_CLIENT_SECRET")
        return
    }

    if db, err = sql.Open("postgres", databaseURL); err != nil {
        log.Fatalf("could not open database connection: %v\n", err)
        return
    }
    defer db.Close()
    if err = db.Ping(); err != nil {
        log.Fatalf("could not ping to db: %v\n", err)
        return
    }

    githubRedirectURL := *origin
    githubRedirectURL.Path = "/api/oauth/github/callback"
    githubOAuthConfig = &oauth2.Config{
        ClientID:     githubClientID,
        ClientSecret: githubClientSecret,
        Endpoint:     github.Endpoint,
        RedirectURL:  githubRedirectURL.String(),
        Scopes:       []string{"read:user"},
    }

    cookieSigner = securecookie.New([]byte(hashKey), nil).MaxAge(0)

    jwtSigner, err = jwt.HS256.New([]byte(jwtKey))
    if err != nil {
        log.Fatalf("could not create JWT signer: %v\n", err)
        return
    }

    router := way.NewRouter()
    router.HandleFunc("GET", "/api/oauth/github", githubOAuthStart)
    router.HandleFunc("GET", "/api/oauth/github/callback", githubOAuthCallback)
    router.HandleFunc("GET", "/api/auth_user", guard(getAuthUser))

    log.Printf("accepting connections on port %d\n", port)
    log.Printf("starting server at %s\n", origin.String())
    addr := fmt.Sprintf(":%d", port)
    if err = http.ListenAndServe(addr, router); err != nil {
        log.Fatalf("could not start server: %v\n", err)
    }
}

func env(key, fallbackValue string) string {
    v, ok := os.LookupEnv(key)
    if !ok {
        return fallbackValue
    }
    return v
}

func intEnv(key string, fallbackValue int) int {
    v, ok := os.LookupEnv(key)
    if !ok {
        return fallbackValue
    }
    i, err := strconv.Atoi(v)
    if err != nil {
        return fallbackValue
    }
    return i
}

安裝依賴項:

go get -u github.com/gorilla/securecookie
go get -u github.com/joho/godotenv
go get -u github.com/knq/jwt
go get -u github.com/lib/pq
ge get -u github.com/matoous/go-nanoid
go get -u github.com/matryer/way
go get -u golang.org/x/oauth2

我們將會使用 .env 檔案來儲存金鑰和其他配置。請建立這個檔案,並保證裡面至少包含以下內容:

GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

我們還要用到的其他環境變數有:

  • PORT:伺服器執行的埠,預設值是 3000
  • ORIGIN:你的域名,預設值是 http://localhost:3000/。我們也可以在這裡指定埠。
  • DATABASE_URL:Cockroach 資料庫的地址。預設值是 postgresql://root@127.0.0.1:26257/messenger?sslmode=disable
  • HASH_KEY:用於為 cookie 簽名的金鑰。沒錯,我們會使用已簽名的 cookie 來確保安全。
  • JWT_KEY:用於簽署 JSON 網路令牌Web Token的金鑰。

因為程式碼中已經設定了預設值,所以你也不用把它們寫到 .env 檔案中。

在讀取配置並連線到資料庫之後,我們會建立一個 OAuth 配置。我們會使用 ORIGIN 資訊來構建回撥 URL(就和我們在 GitHub 頁面上註冊的一樣)。我們的資料範圍設定為 “read:user”。這會允許我們讀取公開的使用者資訊,這裡我們只需要他的使用者名稱和頭像就夠了。然後我們會初始化 cookie 和 JWT 簽名器。定義一些端點並啟動伺服器。

在實現 HTTP 處理程式之前,讓我們編寫一些函式來傳送 HTTP 響應。

func respond(w http.ResponseWriter, v interface{}, statusCode int) {
    b, err := json.Marshal(v)
    if err != nil {
        respondError(w, fmt.Errorf("could not marshal response: %v", err))
        return
    }
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(statusCode)
    w.Write(b)
}

func respondError(w http.ResponseWriter, err error) {
    log.Println(err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

第一個函式用來傳送 JSON,而第二個將錯誤記錄到控制檯並返回一個 500 Internal Server Error 錯誤資訊。

OAuth 開始

所以,使用者點選寫著 “Access with GitHub” 的連結。該連結指向 /api/oauth/github,這將會把使用者重定向到 github。

func githubOAuthStart(w http.ResponseWriter, r *http.Request) {
    state, err := gonanoid.Nanoid()
    if err != nil {
        respondError(w, fmt.Errorf("could not generte state: %v", err))
        return
    }

    stateCookieValue, err := cookieSigner.Encode("state", state)
    if err != nil {
        respondError(w, fmt.Errorf("could not encode state cookie: %v", err))
        return
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "state",
        Value:    stateCookieValue,
        Path:     "/api/oauth/github",
        HttpOnly: true,
    })
    http.Redirect(w, r, githubOAuthConfig.AuthCodeURL(state), http.StatusTemporaryRedirect)
}

OAuth2 使用一種機制來防止 CSRF 攻擊,因此它需要一個“狀態”(state)。我們使用 Nanoid() 來建立一個隨機字串,並用這個字串作為狀態。我們也把它儲存為一個 cookie。

OAuth 回撥

一旦使用者授權我們訪問他的個人資訊,他將會被重定向到這個端點。這個 URL 的查詢字串上將會包含狀態(state)和授權碼(code): /api/oauth/github/callback?state=&code=

const jwtLifetime = time.Hour * 24 * 14

type GithubUser struct {
    ID        int     `json:"id"`
    Login     string  `json:"login"`
    AvatarURL *string `json:"avatar_url,omitempty"`
}

type User struct {
    ID        string  `json:"id"`
    Username  string  `json:"username"`
    AvatarURL *string `json:"avatarUrl"`
}

func githubOAuthCallback(w http.ResponseWriter, r *http.Request) {
    stateCookie, err := r.Cookie("state")
    if err != nil {
        http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
        return
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "state",
        Value:    "",
        MaxAge:   -1,
        HttpOnly: true,
    })

    var state string
    if err = cookieSigner.Decode("state", stateCookie.Value, &state); err != nil {
        http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
        return
    }

    q := r.URL.Query()

    if state != q.Get("state") {
        http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
        return
    }

    ctx := r.Context()

    t, err := githubOAuthConfig.Exchange(ctx, q.Get("code"))
    if err != nil {
        respondError(w, fmt.Errorf("could not fetch github token: %v", err))
        return
    }

    client := githubOAuthConfig.Client(ctx, t)
    resp, err := client.Get("https://api.github.com/user")
    if err != nil {
        respondError(w, fmt.Errorf("could not fetch github user: %v", err))
        return
    }

    var githubUser GithubUser
    if err = json.NewDecoder(resp.Body).Decode(&githubUser); err != nil {
        respondError(w, fmt.Errorf("could not decode github user: %v", err))
        return
    }
    defer resp.Body.Close()

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        respondError(w, fmt.Errorf("could not begin tx: %v", err))
        return
    }

    var user User
    if err = tx.QueryRowContext(ctx, `
        SELECT id, username, avatar_url FROM users WHERE github_id = $1
    `, githubUser.ID).Scan(&user.ID, &user.Username, &user.AvatarURL); err == sql.ErrNoRows {
        if err = tx.QueryRowContext(ctx, `
            INSERT INTO users (username, avatar_url, github_id) VALUES ($1, $2, $3)
            RETURNING id
        `, githubUser.Login, githubUser.AvatarURL, githubUser.ID).Scan(&user.ID); err != nil {
            respondError(w, fmt.Errorf("could not insert user: %v", err))
            return
        }
        user.Username = githubUser.Login
        user.AvatarURL = githubUser.AvatarURL
    } else if err != nil {
        respondError(w, fmt.Errorf("could not query user by github ID: %v", err))
        return
    }

    if err = tx.Commit(); err != nil {
        respondError(w, fmt.Errorf("could not commit to finish github oauth: %v", err))
        return
    }

    exp := time.Now().Add(jwtLifetime)
    token, err := jwtSigner.Encode(jwt.Claims{
        Subject:    user.ID,
        Expiration: json.Number(strconv.FormatInt(exp.Unix(), 10)),
    })
    if err != nil {
        respondError(w, fmt.Errorf("could not create token: %v", err))
        return
    }

    expiresAt, _ := exp.MarshalText()

    data := make(url.Values)
    data.Set("token", string(token))
    data.Set("expires_at", string(expiresAt))

    http.Redirect(w, r, "/callback?"+data.Encode(), http.StatusTemporaryRedirect)
}

首先,我們會嘗試使用之前儲存的狀態對 cookie 進行解碼。並將其與查詢字串中的狀態進行比較。如果它們不匹配,我們會返回一個 418 I'm teapot(未知來源)錯誤。

接著,我們使用授權碼生成一個令牌。這個令牌被用於建立 HTTP 客戶端來向 GitHub API 發出請求。所以最終我們會向 https://api.github.com/user 傳送一個 GET 請求。這個端點將會以 JSON 格式向我們提供當前經過身份驗證的使用者資訊。我們將會解碼這些內容,一併獲取使用者的 ID、登入名(使用者名稱)和頭像 URL。

然後我們將會嘗試在資料庫上找到具有該 GitHub ID 的使用者。如果沒有找到,就使用該資料建立一個新的。

之後,對於新建立的使用者,我們會發出一個將使用者 ID 作為主題(Subject)的 JSON 網路令牌,並使用該令牌重定向到前端,查詢字串中一併包含該令牌的到期日(Expiration)。

這一 Web 應用也會被用在其他帖子,但是重定向的連結會是 /callback?token=&expires_at=。在那裡,我們將會利用 JavaScript 從 URL 中獲取令牌和到期日,並通過 Authorization 標頭中的令牌以 Bearer token_here 的形式對 /api/auth_user 進行 GET 請求,來獲取已認證的身份使用者並將其儲存到 localStorage。

Guard 中介軟體

為了獲取當前已經過身份驗證的使用者,我們設計了 Guard 中介軟體。這是因為在接下來的文章中,我們會有很多需要進行身份認證的端點,而中介軟體將會允許我們共享這一功能。

type ContextKey struct {
    Name string
}

var keyAuthUserID = ContextKey{"auth_user_id"}

func guard(handler http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var token string
        if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
            token = a[7:]
        } else if t := r.URL.Query().Get("token"); t != "" {
            token = t
        } else {
            http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }

        var claims jwt.Claims
        if err := jwtSigner.Decode([]byte(token), &claims); err != nil {
            http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }

        ctx := r.Context()
        ctx = context.WithValue(ctx, keyAuthUserID, claims.Subject)

        handler(w, r.WithContext(ctx))
    }
}

首先,我們嘗試從 Authorization 標頭或者是 URL 查詢字串中的 token 欄位中讀取令牌。如果沒有找到,我們需要返回 401 Unauthorized(未授權)錯誤。然後我們將會對令牌中的申明進行解碼,並使用該主題作為當前已經過身份驗證的使用者 ID。

現在,我們可以用這一中介軟體來封裝任何需要授權的 http.handlerFunc,並且在處理函式的上下文中保有已經過身份驗證的使用者 ID。

var guarded = guard(func(w http.ResponseWriter, r *http.Request) {
    authUserID := r.Context().Value(keyAuthUserID).(string)
})

獲取認證使用者

func getAuthUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    authUserID := ctx.Value(keyAuthUserID).(string)

    var user User
    if err := db.QueryRowContext(ctx, `
        SELECT username, avatar_url FROM users WHERE id = $1
    `, authUserID).Scan(&user.Username, &user.AvatarURL); err == sql.ErrNoRows {
        http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
        return
    } else if err != nil {
        respondError(w, fmt.Errorf("could not query auth user: %v", err))
        return
    }

    user.ID = authUserID

    respond(w, user, http.StatusOK)
}

我們使用 Guard 中介軟體來獲取當前經過身份認證的使用者 ID 並查詢資料庫。

這一部分涵蓋了後端的 OAuth 流程。在下一篇帖子中,我們將會看到如何開始與其他使用者的對話。


via: https://nicolasparada.netlify.com/posts/go-messenger-oauth/

作者:Nicolás Parada 選題:lujun9972 譯者:PsiACE 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

構建一個即時訊息應用(二):OAuth

訂閱“Linux 中國”官方小程式來檢視

相關文章