无密码验证可以让你只输入一个 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 节点:
它会输出一些内容,找到 SQL 地址行,它将显示像 postgresql://root@127.0.0.1:26257?sslmode=disable
这样的内容。稍后我们将使用它去连接到数据库。
使用如下的内容去创建一个 schema.sql
文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
命令去运行这个脚本:
1 2 3 cat schema .sql | cockroach sql
环境配置 需要配置两个环境变量:SMTP_USERNAME
和 SMTP_PASSWORD
,你可以从你的 mailtrap 帐户中获得它们。将在我们的程序中用到它们。
Go 依赖 我们需要下列的 Go 包:
1 2 3 4 5 go get -u github.com /lib/pqgo get -u github.com /matryer/waygo get -u github.com /dgrijalva/jwt-go
代码 初始化函数 创建 main.go
并且通过 init
函数里的环境变量中取得一些配置来启动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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
函数允许我们去获得环境变量,不存在时返回一个回退值。
主函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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: %v\n" , err ) } defer db .Close () if err = db .Ping(); err != nil { log .Fatalf("could not ping to database: %v\n" , 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: %v\n" , http.ListenAndServe(addr, router)) }
首先,打开数据库连接。记得要加载驱动。
1 2 3 4 5 import ( _ "github.com/lib/pq" )
然后,我们创建路由器并定义一些端点。对于无密码流程来说,我们使用两个端点:/api/passwordless/start
发送魔法链接,和 /api/passwordless/verify_redirect
用 JWT 响应。
最后,我们启动服务器。
你可以创建空处理程序和中间件去测试服务器启动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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) } }
接下来:
1 2 3 4 go build ./passwordless-demo
我们在目录中有了一个 “passwordless-demo”,但是你的目录中可能与示例不一样,go build
将创建一个同名的可执行文件。如果你没有关闭前面的 cockroach 节点,并且你正确配置了 SMTP_USERNAME
和 SMTP_PASSWORD
变量,你将看到命令 starting server at http://localhost/
没有错误输出。
请求 JSON 的中间件 端点需要从请求体中解码 JSON,因此要确保请求是 application/json
类型。因为它是一个通用的东西,我将它解耦到中间件。
1 2 3 4 5 6 7 8 9 10 11 12 13 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 响应是非常通用的做法,因此我把它提取到函数中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
的函数,但是也同时将错误输出到控制台。
1 2 3 4 5 6 7 8 func respondInternalError (w http.ResponseWriter, err error) { log.Println (err) respondJSON (w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError ) }
创建用户的处理程序 下面开始编写 createUser
处理程序,因为它非常容易并且是 REST 式的。
1 2 3 4 5 6 7 type User struct { ID string `json:"id" ` Email string `json:"email" ` Username string `json:"username" ` }
User
类型和 users
表相似。
1 2 3 4 5 6 var ( rxEmail = regexp.MustCompile ("^[^\\ s@]+@[^\\ s@]+\\ .[^\\ s@]+$" ) rxUsername = regexp.MustCompile ("^[a-zA-Z][\\ w|-]{1,17}$" ) )
这些正则表达式是分别用于去验证电子邮件和用户名的。这些都很简单,可以根据你的需要随意去适配。
现在,在 createUser
函数内部,我们将开始解码请求体。
1 2 3 4 5 6 7 8 var user Userif err := json.NewDecoder(r.Body).Decode (&user); err != nil { respondJSON(w, err .Error (), http.StatusBadRequest) return } defer r.Body.Close ()
我们将使用请求体去创建一个 JSON 解码器来解码出一个用户指针。如果发生错误则返回一个 400 Bad Request
。不要忘记关闭请求体读取器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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
去返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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.( 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
或者返回一个内部错误。
1 2 3 respondJSON (w, user, http.StatusCreated)
最后使用创建的用户去响应。
无密码验证开始部分的处理程序 1 2 3 4 5 6 type PasswordlessStartRequest struct { Email string `json:"email"` RedirectURI string `json:"redirectUri"` }
这个结构体含有 passwordlessStart
的请求体:希望去登入的用户 email、来自客户端的重定向 URI(这个应用中将使用我们的 API)如:https://frontend.app/callback
。
1 2 3 var magicLinkTmpl = template .Must (template .ParseFiles ("templates/magic-link.html" ))
我们将使用 golang 模板引擎去构建邮件,因此需要你在 templates
目录中,用如下的内容创建一个 magic-link.html
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!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
函数内部:
1 2 3 4 5 6 7 8 var input PasswordlessStartRequestif err := json.NewDecoder(r.Body).Decode (&input ); err != nil { respondJSON(w, err .Error (), http.StatusBadRequest) return } defer r.Body.Close ()
首先,我们像前面一样解码请求体。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 是否为绝对地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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.( 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
或者一个内部错误。
1 2 3 4 5 6 7 8 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
。
1 2 3 4 5 6 7 8 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 }
我们将得到的魔法链接模板的内容保存到缓冲区中。如果发生错误则返回一个内部错误。
1 2 3 4 5 6 7 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
函数。如果发生错误则返回一个内部错误。
最后,设置响应状态码为 204 No Content
。对于成功的状态码,客户端不需要很多数据。
发送邮件函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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: %s\r\n" , k, v) } msg += "\r\n" msg += body return smtp.SendMail( config.smtpAddr, config.smtpAuth, from.Address, []string {to.Address}, []byte (msg)) }
这个函数创建一个基本的 HTML 邮件结构体并使用 SMTP 服务器去发送它。邮件的内容你可以随意定制,我喜欢使用比较简单的内容。
无密码验证重定向的处理程序 1 2 3 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
函数内部:
1 2 3 4 5 q := r.URL.Query () verificationCode := q.Get ("verification_code" ) redirectURI := q.Get ("redirect_uri" )
/api/passwordless/verify_redirect
是一个 GET
端点,以便于我们从查询字符串中读取数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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.URLvar 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
变量中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var userID stringif 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
。如果没有检索到内容,意味着代码不存在或者已过期,我们返回一个响应信息,否则就返回一个内部错误。
1 2 3 4 5 6 7 8 9 10 11 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 周),并添加一个新端点去刷新令牌,但是不要搞的过于复杂。
1 2 3 4 5 6 7 8 9 10 11 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 库来解码它,因此为了简化,我将它加到这里。
1 2 3 http .Redirect(w, r, callback.String(), http .StatusFound)
最后我们使用一个 302 Found
重定向。
无密码的流程已经完成。现在需要去写 getAuthUser
端点的代码了,它用于获取当前验证用户的信息。你应该还记得,这个端点使用了 guard
中间件。
使用 Auth 中间件 在编写 guard
中间件之前,我将编写一个不需要验证的分支。目的是,如果没有传递 JWT,它将不去验证用户。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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 中间件 1 2 3 4 5 6 7 8 9 10 11 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
处理程序内部:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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
函数。
1 2 3 4 5 6 7 8 9 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
本文由 LCTT 原创编译,Linux中国 荣誉推出