用某語言API實現讓伺服器無密碼驗證

天府雲創發表於2018-09-11

無密碼驗證可以讓你只輸入一個 email 而無需輸入密碼即可登入系統。這是一種比傳統的電子郵件/密碼驗證方式登入更安全的方法。

下面我將為你展示,如何在 Go 中實現一個 HTTP API 去提供這種服務。

流程

  • 使用者輸入他的電子郵件地址。
  • 伺服器建立一個臨時的一次性使用的程式碼(就像一個臨時密碼一樣)關聯到使用者,然後給使用者郵箱中傳送一個“魔法連結”。
  • 使用者點選魔法連結。
  • 伺服器提取魔法連結中的程式碼,獲取關聯的使用者,並且使用一個新的 JWT 重定向到客戶端。
  • 在每次有新請求時,客戶端使用 JWT 去驗證使用者。

必需條件

  • 資料庫:我們為這個服務使用了一個叫 CockroachDB 的 SQL 資料庫。它非常像 postgres,但它是用 Go 寫的。
  • SMTP 伺服器:我們將使用一個第三方的郵件伺服器去傳送郵件。開發的時我們使用 mailtrap。Mailtrap 傳送所有的郵件到它的收件箱,因此,你在測試時不需要建立多個假郵件帳戶。

從 Go 的主頁 上安裝它,然後使用 go version(1.10.1 atm)命令去檢查它能否正常工作。

從 CockroachDB 的主頁 上下載它,展開它並新增到你的 PATH 變數中。使用 cockroach version(2.0 atm)命令檢查它能否正常工作。

資料庫模式

現在,我們在 GOPATH 目錄下為這個專案建立一個目錄,然後使用 cockroach start 啟動一個新的 CockroachDB 節點:

  1. cockroach start --insecure --host 127.0.0.1

它會輸出一些內容,找到 SQL 地址行,它將顯示像 postgresql://root@127.0.0.1:26257?sslmode=disable 這樣的內容。稍後我們將使用它去連線到資料庫。

使用如下的內容去建立一個 schema.sql 檔案。


 
  1. DROP DATABASE IF EXISTS passwordless_demo CASCADE;
  2. CREATE DATABASE IF NOT EXISTS passwordless_demo;
  3. SET DATABASE = passwordless_demo;
  4.  
  5. CREATE TABLE IF NOT EXISTS users (
  6. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  7. email STRING UNIQUE,
  8. username STRING UNIQUE
  9. );
  10.  
  11. CREATE TABLE IF NOT EXISTS verification_codes (
  12. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  13. user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
  14. created_at TIMESTAMPTZ NOT NULL DEFAULT now()
  15. );
  16.  
  17. INSERT INTO users (email, username) VALUES
  18. ('john@passwordless.local', 'john_doe');
  19.  

這個指令碼建立了一個名為 passwordless_demo 的資料庫、兩個名為 users 和 verification_codes 的表,以及為了稍後測試而插入的一些假使用者。每個驗證程式碼都與使用者關聯並儲存建立時間,以用於去檢查驗證程式碼是否過期。

在另外的終端中使用 cockroach sql 命令去執行這個指令碼:


 
  1. cat schema.sql | cockroach sql --insecure
  2.  

環境配置

需要配置兩個環境變數:SMTP_USERNAME 和 SMTP_PASSWORD,你可以從你的 mailtrap 帳戶中獲得它們。將在我們的程式中用到它們。

Go 依賴

我們需要下列的 Go 包:


 
  1. go get -u github.com/lib/pq
  2. go get -u github.com/matryer/way
  3. go get -u github.com/dgrijalva/jwt-go
  4.  

程式碼

初始化函式

建立 main.go 並且通過 init 函式裡的環境變數中取得一些配置來啟動。


 
  1. var config struct {
  2. port int
  3. appURL *url.URL
  4. databaseURL string
  5. jwtKey []byte
  6. smtpAddr string
  7. smtpAuth smtp.Auth
  8. }
  9.  
  10. func init() {
  11. config.port, _ = strconv.Atoi(env("PORT", "80"))
  12. config.appURL, _ = url.Parse(env("APP_URL", "http://localhost:"+strconv.Itoa(config.port)+"/"))
  13. config.databaseURL = env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/passwordless_demo?sslmode=disable")
  14. config.jwtKey = []byte(env("JWT_KEY", "super-duper-secret-key"))
  15. smtpHost := env("SMTP_HOST", "smtp.mailtrap.io")
  16. config.smtpAddr = net.JoinHostPort(smtpHost, env("SMTP_PORT", "25"))
  17. smtpUsername, ok := os.LookupEnv("SMTP_USERNAME")
  18. if !ok {
  19. log.Fatalln("could not find SMTP_USERNAME on environment variables")
  20. }
  21. smtpPassword, ok := os.LookupEnv("SMTP_PASSWORD")
  22. if !ok {
  23. log.Fatalln("could not find SMTP_PASSWORD on environment variables")
  24. }
  25. config.smtpAuth = smtp.PlainAuth("", smtpUsername, smtpPassword, smtpHost)
  26. }
  27.  
  28. func env(key, fallbackValue string) string {
  29. v, ok := os.LookupEnv(key)
  30. if !ok {
  31. return fallbackValue
  32. }
  33. return v
  34. }
  35.  
  • appURL 將去構建我們的 “魔法連結”。
  • port 將要啟動的 HTTP 伺服器。
  • databaseURL 是 CockroachDB 地址,我新增 /passwordless_demo 前面的資料庫地址去表示資料庫名字。
  • jwtKey 用於簽名 JWT。
  • smtpAddr 是 SMTP_HOST + SMTP_PORT 的聯合;我們將使用它去傳送郵件。
  • smtpUsername 和 smtpPassword 是兩個必需的變數。
  • smtpAuth 也是用於傳送郵件。

env 函式允許我們去獲得環境變數,不存在時返回一個回退值。

主函式


 
  1. var db *sql.DB
  2.  
  3. func main() {
  4. var err error
  5. if db, err = sql.Open("postgres", config.databaseURL); err != nil {
  6. log.Fatalf("could not open database connection: %v\n", err)
  7. }
  8. defer db.Close()
  9. if err = db.Ping(); err != nil {
  10. log.Fatalf("could not ping to database: %v\n", err)
  11. }
  12.  
  13. router := way.NewRouter()
  14. router.HandleFunc("POST", "/api/users", jsonRequired(createUser))
  15. router.HandleFunc("POST", "/api/passwordless/start", jsonRequired(passwordlessStart))
  16. router.HandleFunc("GET", "/api/passwordless/verify_redirect", passwordlessVerifyRedirect)
  17. router.Handle("GET", "/api/auth_user", authRequired(getAuthUser))
  18.  
  19. addr := fmt.Sprintf(":%d", config.port)
  20. log.Printf("starting server at %s \n", config.appURL)
  21. log.Fatalf("could not start server: %v\n", http.ListenAndServe(addr, router))
  22. }
  23.  

首先,開啟資料庫連線。記得要載入驅動。


 
  1. import (
  2. _ "github.com/lib/pq"
  3. )
  4.  

然後,我們建立路由器並定義一些端點。對於無密碼流程來說,我們使用兩個端點:/api/passwordless/start 傳送魔法連結,和 /api/passwordless/verify_redirect 用 JWT 響應。

最後,我們啟動伺服器。

你可以建立空處理程式和中介軟體去測試伺服器啟動。


 
  1. func createUser(w http.ResponseWriter, r *http.Request) {
  2. http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
  3. }
  4.  
  5. func passwordlessStart(w http.ResponseWriter, r *http.Request) {
  6. http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
  7. }
  8.  
  9. func passwordlessVerifyRedirect(w http.ResponseWriter, r *http.Request) {
  10. http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
  11. }
  12.  
  13. func getAuthUser(w http.ResponseWriter, r *http.Request) {
  14. http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
  15. }
  16.  
  17. func jsonRequired(next http.HandlerFunc) http.HandlerFunc {
  18. return func(w http.ResponseWriter, r *http.Request) {
  19. next(w, r)
  20. }
  21. }
  22.  
  23. func authRequired(next http.HandlerFunc) http.HandlerFunc {
  24. return func(w http.ResponseWriter, r *http.Request) {
  25. next(w, r)
  26. }
  27. }
  28.  

接下來:


 
  1. go build
  2. ./passwordless-demo
  3.  

我們在目錄中有了一個 “passwordless-demo”,但是你的目錄中可能與示例不一樣,go build 將建立一個同名的可執行檔案。如果你沒有關閉前面的 cockroach 節點,並且你正確配置了 SMTP_USERNAME 和 SMTP_PASSWORD 變數,你將看到命令 starting server at http://localhost/ 沒有錯誤輸出。

請求 JSON 的中介軟體

端點需要從請求體中解碼 JSON,因此要確保請求是 application/json 型別。因為它是一個通用的東西,我將它解耦到中介軟體。


 
  1. func jsonRequired(next http.HandlerFunc) http.HandlerFunc {
  2. return func(w http.ResponseWriter, r *http.Request) {
  3. ct := r.Header.Get("Content-Type")
  4. isJSON := strings.HasPrefix(ct, "application/json")
  5. if !isJSON {
  6. respondJSON(w, "JSON body required", http.StatusUnsupportedMediaType)
  7. return
  8. }
  9. next(w, r)
  10. }
  11. }
  12.  

實現很容易。首先它從請求頭中獲得內容的型別,然後檢查它是否是以 “application/json” 開始,如果不是則以 415 Unsupported Media Type 提前返回。

響應 JSON 的函式

以 JSON 響應是非常通用的做法,因此我把它提取到函式中。


 
  1. func respondJSON(w http.ResponseWriter, payload interface{}, code int) {
  2. switch value := payload.(type) {
  3. case string:
  4. payload = map[string]string{"message": value}
  5. case int:
  6. payload = map[string]int{"value": value}
  7. case bool:
  8. payload = map[string]bool{"result": value}
  9. }
  10. b, err := json.Marshal(payload)
  11. if err != nil {
  12. respondInternalError(w, fmt.Errorf("could not marshal response payload: %v", err))
  13. return
  14. }
  15. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  16. w.WriteHeader(code)
  17. w.Write(b)
  18. }
  19.  

首先,對原始型別做一個型別判斷,並將它們封裝到一個 map。然後將它們編組到 JSON,設定響應內容型別和狀態碼,並寫 JSON。如果 JSON 編組失敗,則響應一個內部錯誤。

響應內部錯誤的函式

respondInternalError 是一個響應 500 Internal Server Error 的函式,但是也同時將錯誤輸出到控制檯。


 
  1. func respondInternalError(w http.ResponseWriter, err error) {
  2. log.Println(err)
  3. respondJSON(w,
  4. http.StatusText(http.StatusInternalServerError),
  5. http.StatusInternalServerError)
  6. }
  7.  

建立使用者的處理程式

下面開始編寫 createUser 處理程式,因為它非常容易並且是 REST 式的。


 
  1. type User struct {
  2. ID string `json:"id"`
  3. Email string `json:"email"`
  4. Username string `json:"username"`
  5. }
  6.  

User 型別和 users 表相似。


 
  1. var (
  2. rxEmail = regexp.MustCompile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
  3. rxUsername = regexp.MustCompile("^[a-zA-Z][\\w|-]{1,17}$")
  4. )
  5.  

這些正規表示式是分別用於去驗證電子郵件和使用者名稱的。這些都很簡單,可以根據你的需要隨意去適配。

現在,在 createUser 函式內部,我們將開始解碼請求體。


 
  1. var user User
  2. if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
  3. respondJSON(w, err.Error(), http.StatusBadRequest)
  4. return
  5. }
  6. defer r.Body.Close()
  7.  

我們將使用請求體去建立一個 JSON 解碼器來解碼出一個使用者指標。如果發生錯誤則返回一個 400 Bad Request。不要忘記關閉請求體讀取器。


 
  1. errs := make(map[string]string)
  2. if user.Email == "" {
  3. errs["email"] = "Email required"
  4. } else if !rxEmail.MatchString(user.Email) {
  5. errs["email"] = "Invalid email"
  6. }
  7. if user.Username == "" {
  8. errs["username"] = "Username required"
  9. } else if !rxUsername.MatchString(user.Username) {
  10. errs["username"] = "Invalid username"
  11. }
  12. if len(errs) != 0 {
  13. respondJSON(w, errs, http.StatusUnprocessableEntity)
  14. return
  15. }
  16.  

這是我如何做驗證;一個簡單的 map 並檢查如果 len(errs) != 0,則使用 422 Unprocessable Entity 去返回。


 
  1. err := db.QueryRowContext(r.Context(), `
  2. INSERT INTO users (email, username) VALUES ($1, $2)
  3. RETURNING id
  4. `, user.Email, user.Username).Scan(&user.ID)
  5.  
  6. if errPq, ok := err.(*pq.Error); ok && errPq.Code.Name() == "unique_violation" {
  7. if strings.Contains(errPq.Error(), "email") {
  8. errs["email"] = "Email taken"
  9. } else {
  10. errs["username"] = "Username taken"
  11. }
  12. respondJSON(w, errs, http.StatusForbidden)
  13. return
  14. } else if err != nil {
  15. respondInternalError(w, fmt.Errorf("could not insert user: %v", err))
  16. return
  17. }
  18.  

這個 SQL 查詢使用一個給定的 email 和使用者名稱去插入一個新使用者,並返回自動生成的 id,每個 $ 將被接下來傳遞給 QueryRowContext 的引數替換掉。

因為 users 表在 email 和 username 欄位上有唯一性約束,因此我將檢查 “unique_violation” 錯誤並返回 403 Forbidden 或者返回一個內部錯誤。


 
  1. respondJSON(w, user, http.StatusCreated)
  2.  

最後使用建立的使用者去響應。

無密碼驗證開始部分的處理程式


 
  1. type PasswordlessStartRequest struct {
  2. Email string `json:"email"`
  3. RedirectURI string `json:"redirectUri"`
  4. }
  5.  

這個結構體含有 passwordlessStart 的請求體:希望去登入的使用者 email、來自客戶端的重定向 URI(這個應用中將使用我們的 API)如:https://frontend.app/callback


 
  1. var magicLinkTmpl = template.Must(template.ParseFiles("templates/magic-link.html"))
  2.  

我們將使用 golang 模板引擎去構建郵件,因此需要你在 templates 目錄中,用如下的內容建立一個 magic-link.html 檔案:


 
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Magic Link</title>
  7. </head>
  8. <body>
  9. Click <a href="{{ .MagicLink }}" target="_blank">here</a> to login.
  10. <br>
  11. <em>This link expires in 15 minutes and can only be used once.</em>
  12. </body>
  13. </html>
  14.  

這個模板是給使用者傳送魔法連結郵件用的。你可以根據你的需要去隨意調整它。

現在, 進入 passwordlessStart 函式內部:


 
  1. var input PasswordlessStartRequest
  2. if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
  3. respondJSON(w, err.Error(), http.StatusBadRequest)
  4. return
  5. }
  6. defer r.Body.Close()
  7.  

首先,我們像前面一樣解碼請求體。


 
  1. errs := make(map[string]string)
  2. if input.Email == "" {
  3. errs["email"] = "Email required"
  4. } else if !rxEmail.MatchString(input.Email) {
  5. errs["email"] = "Invalid email"
  6. }
  7. if input.RedirectURI == "" {
  8. errs["redirectUri"] = "Redirect URI required"
  9. } else if u, err := url.Parse(input.RedirectURI); err != nil || !u.IsAbs() {
  10. errs["redirectUri"] = "Invalid redirect URI"
  11. }
  12. if len(errs) != 0 {
  13. respondJSON(w, errs, http.StatusUnprocessableEntity)
  14. return
  15. }
  16.  

我們使用 golang 的 URL 解析器去驗證重定向 URI,檢查那個 URI 是否為絕對地址。


 
  1. var verificationCode string
  2. err := db.QueryRowContext(r.Context(), `
  3. INSERT INTO verification_codes (user_id) VALUES
  4. ((SELECT id FROM users WHERE email = $1))
  5. RETURNING id
  6. `, input.Email).Scan(&verificationCode)
  7. if errPq, ok := err.(*pq.Error); ok && errPq.Code.Name() == "not_null_violation" {
  8. respondJSON(w, "No user found with that email", http.StatusNotFound)
  9. return
  10. } else if err != nil {
  11. respondInternalError(w, fmt.Errorf("could not insert verification code: %v", err))
  12. return
  13. }
  14.  

這個 SQL 查詢將插入一個驗證程式碼,這個程式碼通過給定的 email 關聯到使用者,並且返回一個自動生成的 id。因為有可能會出現使用者不存在的情況,那樣的話子查詢可能解析為 NULL,這將導致在 user_id 欄位上因違反 NOT NULL 約束而導致失敗,因此需要對這種情況進行檢查,如果使用者不存在,則返回 404 Not Found 或者一個內部錯誤。


 
  1. q := make(url.Values)
  2. q.Set("verification_code", verificationCode)
  3. q.Set("redirect_uri", input.RedirectURI)
  4. magicLink := *config.appURL
  5. magicLink.Path = "/api/passwordless/verify_redirect"
  6. magicLink.RawQuery = q.Encode()
  7.  

現在,構建魔法連結並設定查詢字串中的 verification_code 和 redirect_uri 的值。如:http://localhost/api/passwordless/verify_redirect?verification_code=some_code&redirect_uri=https://frontend.app/callback


 
  1. var body bytes.Buffer
  2. data := map[string]string{"MagicLink": magicLink.String()}
  3. if err := magicLinkTmpl.Execute(&body, data); err != nil {
  4. respondInternalError(w, fmt.Errorf("could not execute magic link template: %v", err))
  5. return
  6. }
  7.  

我們將得到的魔法連結模板的內容儲存到緩衝區中。如果發生錯誤則返回一個內部錯誤。


 
  1. to := mail.Address{Address: input.Email}
  2. if err := sendMail(to, "Magic Link", body.String()); err != nil {
  3. respondInternalError(w, fmt.Errorf("could not mail magic link: %v", err))
  4. return
  5. }
  6.  

現在來寫給使用者發郵件的 sendMail 函式。如果發生錯誤則返回一個內部錯誤。


 
  1. w.WriteHeader(http.StatusNoContent)
  2.  

最後,設定響應狀態碼為 204 No Content。對於成功的狀態碼,客戶端不需要很多資料。

傳送郵件函式


 
  1. func sendMail(to mail.Address, subject, body string) error {
  2. from := mail.Address{
  3. Name: "Passwordless Demo",
  4. Address: "noreply@" + config.appURL.Host,
  5. }
  6. headers := map[string]string{
  7. "From": from.String(),
  8. "To": to.String(),
  9. "Subject": subject,
  10. "Content-Type": `text/html; charset="utf-8"`,
  11. }
  12. msg := ""
  13. for k, v := range headers {
  14. msg += fmt.Sprintf("%s: %s\r\n", k, v)
  15. }
  16. msg += "\r\n"
  17. msg += body
  18.  
  19. return smtp.SendMail(
  20. config.smtpAddr,
  21. config.smtpAuth,
  22. from.Address,
  23. []string{to.Address},
  24. []byte(msg))
  25. }
  26.  

這個函式建立一個基本的 HTML 郵件結構體並使用 SMTP 伺服器去傳送它。郵件的內容你可以隨意定製,我喜歡使用比較簡單的內容。

無密碼驗證重定向的處理程式


 
  1. var rxUUID = regexp.MustCompile("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
  2.  

首先,這個正規表示式去驗證一個 UUID(即驗證程式碼)。

現在進入 passwordlessVerifyRedirect 函式內部:


 
  1. q := r.URL.Query()
  2. verificationCode := q.Get("verification_code")
  3. redirectURI := q.Get("redirect_uri")
  4.  

/api/passwordless/verify_redirect 是一個 GET 端點,以便於我們從查詢字串中讀取資料。


 
  1. errs := make(map[string]string)
  2. if verificationCode == "" {
  3. errs["verification_code"] = "Verification code required"
  4. } else if !rxUUID.MatchString(verificationCode) {
  5. errs["verification_code"] = "Invalid verification code"
  6. }
  7. var callback *url.URL
  8. var err error
  9. if redirectURI == "" {
  10. errs["redirect_uri"] = "Redirect URI required"
  11. } else if callback, err = url.Parse(redirectURI); err != nil || !callback.IsAbs() {
  12. errs["redirect_uri"] = "Invalid redirect URI"
  13. }
  14. if len(errs) != 0 {
  15. respondJSON(w, errs, http.StatusUnprocessableEntity)
  16. return
  17. }
  18.  

類似的驗證,我們儲存解析後的重定向 URI 到一個 callback 變數中。


 
  1. var userID string
  2. if err := db.QueryRowContext(r.Context(), `
  3. DELETE FROM verification_codes
  4. WHERE id = $1
  5. AND created_at >= now() - INTERVAL '15m'
  6. RETURNING user_id
  7. `, verificationCode).Scan(&userID); err == sql.ErrNoRows {
  8. respondJSON(w, "Link expired or already used", http.StatusBadRequest)
  9. return
  10. } else if err != nil {
  11. respondInternalError(w, fmt.Errorf("could not delete verification code: %v", err))
  12. return
  13. }
  14.  

這個 SQL 查詢通過給定的 id 去刪除相應的驗證程式碼,並且確保它建立之後時間不超過 15 分鐘,它也返回關聯的 user_id。如果沒有檢索到內容,意味著程式碼不存在或者已過期,我們返回一個響應資訊,否則就返回一個內部錯誤。


 
  1. expiresAt := time.Now().Add(time.Hour * 24 * 60)
  2. tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
  3. Subject: userID,
  4. ExpiresAt: expiresAt.Unix(),
  5. }).SignedString(config.jwtKey)
  6. if err != nil {
  7. respondInternalError(w, fmt.Errorf("could not create JWT: %v", err))
  8. return
  9. }
  10.  

這些是如何去建立 JWT。我們為 JWT 設定一個 60 天的過期值,你也可以設定更短的時間(大約 2 周),並新增一個新端點去重新整理令牌,但是不要搞的過於複雜。


 
  1. expiresAtB, err := expiresAt.MarshalText()
  2. if err != nil {
  3. respondInternalError(w, fmt.Errorf("could not marshal expiration date: %v", err))
  4. return
  5. }
  6. f := make(url.Values)
  7. f.Set("jwt", tokenString)
  8. f.Set("expires_at", string(expiresAtB))
  9. callback.Fragment = f.Encode()
  10.  

我們去規劃重定向;你可使用查詢字串去新增 JWT,但是更常見的是使用一個雜湊片段。如:https://frontend.app/callback#jwt=token_here&expires_at=some_date.

過期日期可以從 JWT 中提取出來,但是這樣做的話,就需要在客戶端上實現一個 JWT 庫來解碼它,因此為了簡化,我將它加到這裡。


 
  1. http.Redirect(w, r, callback.String(), http.StatusFound)
  2.  

最後我們使用一個 302 Found 重定向。


無密碼的流程已經完成。現在需要去寫 getAuthUser 端點的程式碼了,它用於獲取當前驗證使用者的資訊。你應該還記得,這個端點使用了 guard 中介軟體。

使用 Auth 中介軟體

在編寫 guard 中介軟體之前,我將編寫一個不需要驗證的分支。目的是,如果沒有傳遞 JWT,它將不去驗證使用者。


 
  1. type ContextKey struct {
  2. Name string
  3. }
  4.  
  5. var keyAuthUserID = ContextKey{"auth_user_id"}
  6.  
  7. func withAuth(next http.HandlerFunc) http.HandlerFunc {
  8. return func(w http.ResponseWriter, r *http.Request) {
  9. a := r.Header.Get("Authorization")
  10. hasToken := strings.HasPrefix(a, "Bearer ")
  11. if !hasToken {
  12. next(w, r)
  13. return
  14. }
  15. tokenString := a[7:]
  16.  
  17. p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
  18. token, err := p.ParseWithClaims(
  19. tokenString,
  20. &jwt.StandardClaims{},
  21. func (*jwt.Token) (interface{}, error) { return config.jwtKey, nil },
  22. )
  23. if err != nil {
  24. respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
  25. return
  26. }
  27.  
  28. claims, ok := token.Claims.(*jwt.StandardClaims)
  29. if !ok || !token.Valid {
  30. respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
  31. return
  32. }
  33.  
  34. ctx := r.Context()
  35. ctx = context.WithValue(ctx, keyAuthUserID, claims.Subject)
  36.  
  37. next(w, r.WithContext(ctx))
  38. }
  39. }

JWT 將在每次請求時以 Bearer <token_here> 格式包含在 Authorization 頭中。因此,如果沒有提供令牌,我們將直接通過,進入接下來的中介軟體。

我們建立一個解析器來解析令牌。如果解析失敗則返回 401 Unauthorized

然後我們從 JWT 中提取出要求的內容,並新增 Subject(就是使用者 ID)到需要的地方。

Guard 中介軟體


 
  1. func guard(next http.HandlerFunc) http.HandlerFunc {
  2. return withAuth(func(w http.ResponseWriter, r *http.Request) {
  3. _, ok := r.Context().Value(keyAuthUserID).(string)
  4. if !ok {
  5. respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
  6. return
  7. }
  8. next(w, r)
  9. })
  10. }

現在,guard 將使用 withAuth 並從請求內容中提取出驗證使用者的 ID。如果提取失敗,它將返回 401 Unauthorized,提取成功則繼續下一步。

獲取 Auth 使用者

在 getAuthUser 處理程式內部:


 
  1. ctx := r.Context()
  2. authUserID := ctx.Value(keyAuthUserID).(string)
  3.  
  4. user, err := fetchUser(ctx, authUserID)
  5. if err == sql.ErrNoRows {
  6. respondJSON(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
  7. return
  8. } else if err != nil {
  9. respondInternalError(w, fmt.Errorf("could not query auth user: %v", err))
  10. return
  11. }
  12.  
  13. respondJSON(w, user, http.StatusOK)
  14.  

首先,我們從請求內容中提取驗證使用者的 ID,我們使用這個 ID 去獲取使用者。如果沒有獲取到內容,則傳送一個 418 I'm a teapot,或者一個內部錯誤。最後,我們將用這個使用者去響應。

獲取 User 函式

下面你看到的是 fetchUser 函式。


 
  1. func fetchUser(ctx context.Context, id string) (User, error) {
  2. user := User{ID: id}
  3. err := db.QueryRowContext(ctx, `
  4. SELECT email, username FROM users WHERE id = $1
  5. `, id).Scan(&user.Email, &user.Username)
  6. return user, err
  7. }
  8.  

我將它解耦是因為通過 ID 來獲取使用者是個常做的事。


以上就是全部的程式碼。你可以自己去構建它和測試它。這裡 還有一個 demo 你可以試用一下。

如果你在 mailtrap 上點選之後出現有關 指令碼執行被攔截,因為文件的框架是沙箱化的,並且沒有設定 'allow-scripts' 許可權 的問題,你可以嘗試右鍵點選 “在新標籤中開啟連結“。這樣做是安全的,因為郵件內容是 沙箱化的。我在 localhost 上有時也會出現這個問題,但是我認為你一旦以 https:// 方式部署到伺服器上應該不會出現這個問題了。

如果有任何問題,請在我的 GitHub repo 留言或者提交 PRs

相關文章