Linux中國

無密碼驗證:伺服器

密碼驗證可以讓你只輸入一個 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 節點:

cockroach start --insecure --host 127.0.0.1

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

使用如下的內容去創建一個 schema.sql 文件。

DROP DATABASE IF EXISTS passwordless_demo CASCADE;
CREATE DATABASE IF NOT EXISTS passwordless_demo;
SET DATABASE = passwordless_demo;

CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email STRING UNIQUE,
    username STRING UNIQUE
);

CREATE TABLE IF NOT EXISTS verification_codes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO users (email, username) VALUES
    ('john@passwordless.local', 'john_doe');

這個腳本創建了一個名為 passwordless_demo 的資料庫、兩個名為 usersverification_codes 的表,以及為了稍後測試而插入的一些假用戶。每個驗證代碼都與用戶關聯並保存創建時間,以用於去檢查驗證代碼是否過期。

在另外的終端中使用 cockroach sql 命令去運行這個腳本:

cat schema.sql | cockroach sql --insecure

環境配置

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

Go 依賴

我們需要下列的 Go 包:

go get -u github.com/lib/pq
go get -u github.com/matryer/way
go get -u github.com/dgrijalva/jwt-go

代碼

初始化函數

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

var config struct {
    port        int
    appURL      *url.URL
    databaseURL string
    jwtKey      []byte
    smtpAddr    string
    smtpAuth    smtp.Auth
}

func init() {
    config.port, _ = strconv.Atoi(env("PORT", "80"))
    config.appURL, _ = url.Parse(env("APP_URL", "http://localhost:"+strconv.Itoa(config.port)+"/"))
    config.databaseURL = env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/passwordless_demo?sslmode=disable")
    config.jwtKey = []byte(env("JWT_KEY", "super-duper-secret-key"))
    smtpHost := env("SMTP_HOST", "smtp.mailtrap.io")
    config.smtpAddr = net.JoinHostPort(smtpHost, env("SMTP_PORT", "25"))
    smtpUsername, ok := os.LookupEnv("SMTP_USERNAME")
    if !ok {
        log.Fatalln("could not find SMTP_USERNAME on environment variables")
    }
    smtpPassword, ok := os.LookupEnv("SMTP_PASSWORD")
    if !ok {
        log.Fatalln("could not find SMTP_PASSWORD on environment variables")
    }
    config.smtpAuth = smtp.PlainAuth("", smtpUsername, smtpPassword, smtpHost)
}

func env(key, fallbackValue string) string {
    v, ok := os.LookupEnv(key)
    if !ok {
        return fallbackValue
    }
    return v
}
  • appURL 將去構建我們的 「魔法鏈接」。
  • port 將要啟動的 HTTP 伺服器。
  • databaseURL 是 CockroachDB 地址,我添加 /passwordless_demo 前面的資料庫地址去表示資料庫名字。
  • jwtKey 用於簽名 JWT。
  • smtpAddrSMTP_HOST + SMTP_PORT 的聯合;我們將使用它去發送郵件。
  • smtpUsernamesmtpPassword 是兩個必需的變數。
  • smtpAuth 也是用於發送郵件。

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

主函數

var db *sql.DB

func main() {
    var err error
    if db, err = sql.Open("postgres", config.databaseURL); err != nil {
        log.Fatalf("could not open database connection: %vn", err)
    }
    defer db.Close()
    if err = db.Ping(); err != nil {
        log.Fatalf("could not ping to database: %vn", err)
    }

    router := way.NewRouter()
    router.HandleFunc("POST", "/api/users", jsonRequired(createUser))
    router.HandleFunc("POST", "/api/passwordless/start", jsonRequired(passwordlessStart))
    router.HandleFunc("GET", "/api/passwordless/verify_redirect", passwordlessVerifyRedirect)
    router.Handle("GET", "/api/auth_user", authRequired(getAuthUser))

    addr := fmt.Sprintf(":%d", config.port)
    log.Printf("starting server at %s n", config.appURL)
    log.Fatalf("could not start server: %vn", http.ListenAndServe(addr, router))
}

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

import (
    _ "github.com/lib/pq"
)

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

最後,我們啟動伺服器。

你可以創建空處理程序和中間件去測試伺服器啟動。

func createUser(w http.ResponseWriter, r *http.Request) {
    http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
}

func passwordlessStart(w http.ResponseWriter, r *http.Request) {
    http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
}

func passwordlessVerifyRedirect(w http.ResponseWriter, r *http.Request) {
    http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
}

func getAuthUser(w http.ResponseWriter, r *http.Request) {
    http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
}

func jsonRequired(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        next(w, r)
    }
}

func authRequired(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        next(w, r)
    }
}

接下來:

go build
./passwordless-demo

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

請求 JSON 的中間件

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

func jsonRequired(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ct := r.Header.Get("Content-Type")
        isJSON := strings.HasPrefix(ct, "application/json")
        if !isJSON {
            respondJSON(w, "JSON body required", http.StatusUnsupportedMediaType)
            return
        }
        next(w, r)
    }
}

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

響應 JSON 的函數

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

func respondJSON(w http.ResponseWriter, payload interface{}, code int) {
    switch value := payload.(type) {
    case string:
        payload = map[string]string{"message": value}
    case int:
        payload = map[string]int{"value": value}
    case bool:
        payload = map[string]bool{"result": value}
    }
    b, err := json.Marshal(payload)
    if err != nil {
        respondInternalError(w, fmt.Errorf("could not marshal response payload: %v", err))
        return
    }
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(code)
    w.Write(b)
}

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

響應內部錯誤的函數

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

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

創建用戶的處理程序

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

type User struct {
    ID       string `json:"id"`
    Email    string `json:"email"`
    Username string `json:"username"`
}

User 類型和 users 表相似。

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

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

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

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

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

errs := make(map[string]string)
if user.Email == "" {
    errs["email"] = "Email required"
} else if !rxEmail.MatchString(user.Email) {
    errs["email"] = "Invalid email"
}
if user.Username == "" {
    errs["username"] = "Username required"
} else if !rxUsername.MatchString(user.Username) {
    errs["username"] = "Invalid username"
}
if len(errs) != 0 {
    respondJSON(w, errs, http.StatusUnprocessableEntity)
    return
}

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

err := db.QueryRowContext(r.Context(), `
    INSERT INTO users (email, username) VALUES ($1, $2)
    RETURNING id
`, user.Email, user.Username).Scan(&user.ID)

if errPq, ok := err.(*pq.Error); ok && errPq.Code.Name() == "unique_violation" {
    if strings.Contains(errPq.Error(), "email") {
        errs["email"] = "Email taken"
    } else {
        errs["username"] = "Username taken"
    }
    respondJSON(w, errs, http.StatusForbidden)
    return
} else if err != nil {
    respondInternalError(w, fmt.Errorf("could not insert user: %v", err))
    return
}

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

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

respondJSON(w, user, http.StatusCreated)

最後使用創建的用戶去響應。

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

type PasswordlessStartRequest struct {
    Email       string `json:"email"`
    RedirectURI string `json:"redirectUri"`
}

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

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

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

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

這個模板是給用戶發送魔法鏈接郵件用的。你可以根據你的需要去隨意調整它。

現在, 進入 passwordlessStart 函數內部:

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

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

errs := make(map[string]string)
if input.Email == "" {
    errs["email"] = "Email required"
} else if !rxEmail.MatchString(input.Email) {
    errs["email"] = "Invalid email"
}
if input.RedirectURI == "" {
    errs["redirectUri"] = "Redirect URI required"
} else if u, err := url.Parse(input.RedirectURI); err != nil || !u.IsAbs() {
    errs["redirectUri"] = "Invalid redirect URI"
}
if len(errs) != 0 {
    respondJSON(w, errs, http.StatusUnprocessableEntity)
    return
}

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

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

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

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

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

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

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

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

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

w.WriteHeader(http.StatusNoContent)

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

發送郵件函數

func sendMail(to mail.Address, subject, body string) error {
    from := mail.Address{
        Name:    "Passwordless Demo",
        Address: "noreply@" + config.appURL.Host,
    }
    headers := map[string]string{
        "From":         from.String(),
        "To":           to.String(),
        "Subject":      subject,
        "Content-Type": `text/html; charset="utf-8"`,
    }
    msg := ""
    for k, v := range headers {
        msg += fmt.Sprintf("%s: %srn", k, v)
    }
    msg += "rn"
    msg += body

    return smtp.SendMail(
        config.smtpAddr,
        config.smtpAuth,
        from.Address,
        []string{to.Address},
        []byte(msg))
}

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

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

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}$")

首先,這個正則表達式去驗證一個 UUID(即驗證代碼)。

現在進入 passwordlessVerifyRedirect 函數內部:

q := r.URL.Query()
verificationCode := q.Get("verification_code")
redirectURI := q.Get("redirect_uri")

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

errs := make(map[string]string)
if verificationCode == "" {
    errs["verification_code"] = "Verification code required"
} else if !rxUUID.MatchString(verificationCode) {
    errs["verification_code"] = "Invalid verification code"
}
var callback *url.URL
var err error
if redirectURI == "" {
    errs["redirect_uri"] = "Redirect URI required"
} else if callback, err = url.Parse(redirectURI); err != nil || !callback.IsAbs() {
    errs["redirect_uri"] = "Invalid redirect URI"
}
if len(errs) != 0 {
    respondJSON(w, errs, http.StatusUnprocessableEntity)
    return
}

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

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

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

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

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

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

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

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

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

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

無密碼的流程已經完成。現在需要去寫 getAuthUser 端點的代碼了,它用於獲取當前驗證用戶的信息。你應該還記得,這個端點使用了 guard 中間件。

使用 Auth 中間件

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

type ContextKey struct {
    Name string
}

var keyAuthUserID = ContextKey{"auth_user_id"}

func withAuth(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        a := r.Header.Get("Authorization")
        hasToken := strings.HasPrefix(a, "Bearer ")
        if !hasToken {
            next(w, r)
            return
        }
        tokenString := a[7:]

        p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
        token, err := p.ParseWithClaims(
            tokenString,
            &jwt.StandardClaims{},
            func (*jwt.Token) (interface{}, error) { return config.jwtKey, nil },
        )
        if err != nil {
            respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }

        claims, ok := token.Claims.(*jwt.StandardClaims)
        if !ok || !token.Valid {
            respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }

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

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

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

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

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

Guard 中間件

func guard(next http.HandlerFunc) http.HandlerFunc {
    return withAuth(func(w http.ResponseWriter, r *http.Request) {
        _, ok := r.Context().Value(keyAuthUserID).(string)
        if !ok {
            respondJSON(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }
        next(w, r)
    })
}

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

獲取 Auth 用戶

getAuthUser 處理程序內部:

ctx := r.Context()
authUserID := ctx.Value(keyAuthUserID).(string)

user, err := fetchUser(ctx, authUserID)
if err == sql.ErrNoRows {
    respondJSON(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
    return
} else if err != nil {
    respondInternalError(w, fmt.Errorf("could not query auth user: %v", err))
    return
}

respondJSON(w, user, http.StatusOK)

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

獲取 User 函數

下面你看到的是 fetchUser 函數。

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

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

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

如果你在 mailtrap 上點擊之後出現有關 腳本運行被攔截,因為文檔的框架是沙箱化的,並且沒有設置 &apos;allow-scripts&apos; 許可權 的問題,你可以嘗試右鍵點擊 「在新標籤中打開鏈接「。這樣做是安全的,因為郵件內容是 沙箱化的。我在 localhost 上有時也會出現這個問題,但是我認為你一旦以 https:// 方式部署到伺服器上應該不會出現這個問題了。

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

以後,我為這個 API 寫了一個客戶端作為這篇文章的第二部分

via: https://nicolasparada.netlify.com/posts/passwordless-auth-server/

作者:Nicolás Parada 譯者:qhwdw 校對:wxy

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


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國