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

上一篇:模式html

在這篇帖子中,咱們將會經過爲應用添加社交登陸功能進入後端開發。前端

社交登陸的工做方式十分簡單:用戶點擊連接,而後重定向到 GitHub 受權頁面。當用戶授予咱們對他的我的信息的訪問權限以後,就會重定向回登陸頁面。下一次嘗試登陸時,系統將不會再次請求受權,也就是說,咱們的應用已經記住了這個用戶。這使得整個登陸流程看起來就和你用鼠標單擊同樣快。linux

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

這一步中,比較重要的是回調 URL。咱們將它設置爲 http://localhost:3000/api/oauth/github/callback。這是由於,在開發過程當中,咱們老是在本地主機上工做。一旦你要將應用交付生產,請使用正確的回調 URL 註冊一個新的應用。github

註冊之後,你將會收到「客戶端 id」和「安全密鑰」。安全起見,請不要與任何人分享他們 👀golang

順便讓咱們開始寫一些代碼吧。如今,建立一個 main.go 文件:sql

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 文件來保存密鑰和其餘配置。請建立這個文件,並保證裏面至少包含如下內容:json

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: nicolasparada.netlify.com/posts/go-me…

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

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

相關文章
相關標籤/搜索