用某语言API实现让服务器无密码验证

无密码验证可以让你只输入一个 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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值