无密码验证:服务器 ============================================================ 无密码验证可以让你只输入一个 email 而无需输入密码即可登入系统。这是一种比传统的电子邮件/密码验证方式登入更安全的方法。 下面我将为你展示,如何在 [Go][6] 中实现一个 HTTP API 去提供这种服务。 ### 业务流 * 用户输入他的电子邮件地址。 * 服务器创建一个临时的一次性使用的代码(就像一个临时密码一样)关联到用户,然后给用户邮箱中发送一个“魔法链接”。 * 用户点击魔法链接。 * 服务器提取魔法链接中的代码,获取关联的用户,并且使用一个新的 JWT 重定向到客户端。 * 在每次有新请求时,客户端使用 JWT 去验证用户。 ### 必需条件 * 数据库:我们为这个服务使用了一个叫 [CockroachDB][1] 的 SQL 数据库。它非常像 postgres,但它是用 Go 写的。 * SMTP 服务器:我们将使用一个第三方的邮件服务器去发送邮件。开发的时我们使用 [mailtrap][2]。Mailtrap 发送所有的邮件到它的收件箱,因此,你在测试它们时不需要创建多个假冒邮件帐户。 从 [它的主页][7] 上安装 Go,然后使用 `go version`(1.10.1 atm)命令去检查它能否正常工作。 从 [它的主页][8] 上下载 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][3]:它是 CockroachDB 使用的 postgres 驱动 * [github.com/matryer/way][4]: 路由器 * [github.com/dgrijalva/jwt-go][5]: 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` 用于签名 JWTs。 * `smtpAddr` 是 `SMTP_HOST` + `SMTP_PORT` 的联合;我们将使用它去发送邮件。 * `smtpUsername` 和 `smtpPassword` 是两个必需的变量。 * `smtpAuth` 也是用于发送邮件。 `env` 函数允许我们去获得环境变量,不存在时返回一个 fallback value。 ### 主函数 ``` 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)) } ``` 首先,打开数据库连接。记得要加载驱动。 ``` 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` 文件: ```