構建一個即時消息應用(二):OAuth
在這篇帖子中,我們將會通過為應用添加社交登錄功能進入後端開發。
社交登錄的工作方式十分簡單:用戶點擊鏈接,然後重定向到 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: %vn", err)
return
}
defer db.Close()
if err = db.Ping(); err != nil {
log.Fatalf("could not ping to db: %vn", 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: %vn", 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 %dn", port)
log.Printf("starting server at %sn", origin.String())
addr := fmt.Sprintf(":%d", port)
if err = http.ListenAndServe(addr, router); err != nil {
log.Fatalf("could not start server: %vn", 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 網路令牌 的密鑰。
因為代碼中已經設定了默認值,所以你也不用把它們寫到 .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
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive