無密碼驗證:伺服器
無密碼驗證可以讓你只輸入一個 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
的資料庫、兩個名為 users
和 verification_codes
的表,以及為了稍後測試而插入的一些假用戶。每個驗證代碼都與用戶關聯並保存創建時間,以用於去檢查驗證代碼是否過期。
在另外的終端中使用 cockroach sql
命令去運行這個腳本:
cat schema.sql | cockroach sql --insecure
環境配置
需要配置兩個環境變數:SMTP_USERNAME
和 SMTP_PASSWORD
,你可以從你的 mailtrap 帳戶中獲得它們。將在我們的程序中用到它們。
Go 依賴
我們需要下列的 Go 包:
- github.com/lib/pq:它是 CockroachDB 使用的 postgres 驅動
- github.com/matryer/way: 路由器
- github.com/dgrijalva/jwt-go: JWT 實現
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。smtpAddr
是SMTP_HOST
+SMTP_PORT
的聯合;我們將使用它去發送郵件。smtpUsername
和smtpPassword
是兩個必需的變數。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_USERNAME
和 SMTP_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
表在 email
和 username
欄位上有唯一性約束,因此我將檢查 「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_code
和 redirect_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 '15m'
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'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 上點擊之後出現有關 腳本運行被攔截,因為文檔的框架是沙箱化的,並且沒有設置 'allow-scripts' 許可權
的問題,你可以嘗試右鍵點擊 「在新標籤中打開鏈接「。這樣做是安全的,因為郵件內容是 沙箱化的。我在 localhost
上有時也會出現這個問題,但是我認為你一旦以 https://
方式部署到伺服器上應該不會出現這個問題了。
如果有任何問題,請在我的 GitHub repo 留言或者提交 PRs
以後,我為這個 API 寫了一個客戶端作為這篇文章的第二部分。
via: https://nicolasparada.netlify.com/posts/passwordless-auth-server/
作者:Nicolás Parada 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive