TranslateProject/published/201806/20180418 Passwordless Auth Server.md

794 lines
26 KiB
Markdown
Raw Permalink Normal View History

2018-05-30 15:32:36 +08:00
无密码验证:服务器
2018-04-21 10:34:37 +08:00
============================================================
2018-05-30 15:32:36 +08:00
无密码验证可以让你只输入一个 email 而无需输入密码即可登入系统。这是一种比传统的电子邮件/密码验证方式登入更安全的方法。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
下面我将为你展示,如何在 [Go][6] 中实现一个 HTTP API 去提供这种服务。
2018-04-21 10:34:37 +08:00
### 流程
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
* 用户输入他的电子邮件地址。
* 服务器创建一个临时的一次性使用的代码(就像一个临时密码一样)关联到用户,然后给用户邮箱中发送一个“魔法链接”。
* 用户点击魔法链接。
* 服务器提取魔法链接中的代码,获取关联的用户,并且使用一个新的 JWT 重定向到客户端。
* 在每次有新请求时,客户端使用 JWT 去验证用户。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
### 必需条件
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
* 数据库:我们为这个服务使用了一个叫 [CockroachDB][1] 的 SQL 数据库。它非常像 postgres但它是用 Go 写的。
* SMTP 服务器:我们将使用一个第三方的邮件服务器去发送邮件。开发的时我们使用 [mailtrap][2]。Mailtrap 发送所有的邮件到它的收件箱,因此,你在测试时不需要创建多个假邮件帐户。
2018-04-21 10:34:37 +08:00
从 [Go 的主页][7] 上安装它,然后使用 `go version`1.10.1 atm命令去检查它能否正常工作。
2018-04-21 10:34:37 +08:00
从 [CockroachDB 的主页][8] 上下载它,展开它并添加到你的 `PATH` 变量中。使用 `cockroach version`2.0 atm命令检查它能否正常工作。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
### 数据库模式
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
现在,我们在 `GOPATH` 目录下为这个项目创建一个目录,然后使用 `cockroach start` 启动一个新的 CockroachDB 节点:
2018-04-21 10:34:37 +08:00
```
cockroach start --insecure --host 127.0.0.1
```
2018-05-30 15:32:36 +08:00
它会输出一些内容,找到 SQL 地址行,它将显示像 `postgresql://root@127.0.0.1:26257?sslmode=disable` 这样的内容。稍后我们将使用它去连接到数据库。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
使用如下的内容去创建一个 `schema.sql` 文件。
2018-04-21 10:34:37 +08:00
```
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` 的表,以及为了稍后测试而插入的一些假用户。每个验证代码都与用户关联并保存创建时间,以用于去检查验证代码是否过期。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
在另外的终端中使用 `cockroach sql` 命令去运行这个脚本:
2018-04-21 10:34:37 +08:00
```
cat schema.sql | cockroach sql --insecure
```
2018-05-30 15:32:36 +08:00
### 环境配置
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
需要配置两个环境变量:`SMTP_USERNAME` 和 `SMTP_PASSWORD`,你可以从你的 mailtrap 帐户中获得它们。将在我们的程序中用到它们。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
### Go 依赖
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
我们需要下列的 Go 包:
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
* [github.com/lib/pq][3]:它是 CockroachDB 使用的 postgres 驱动
* [github.com/matryer/way][4]: 路由器
* [github.com/dgrijalva/jwt-go][5]: JWT 实现
2018-04-21 10:34:37 +08:00
```
go get -u github.com/lib/pq
go get -u github.com/matryer/way
go get -u github.com/dgrijalva/jwt-go
```
2018-05-30 15:32:36 +08:00
### 代码
2018-04-21 10:34:37 +08:00
#### 初始化函数
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
创建 `main.go` 并且通过 `init` 函数里的环境变量中取得一些配置来启动。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
* `appURL` 将去构建我们的 “魔法链接”。
* `port` 将要启动的 HTTP 服务器。
* `databaseURL` 是 CockroachDB 地址,我添加 `/passwordless_demo` 前面的数据库地址去表示数据库名字。
* `jwtKey` 用于签名 JWT。
2018-05-30 15:32:36 +08:00
* `smtpAddr` 是 `SMTP_HOST` + `SMTP_PORT` 的联合;我们将使用它去发送邮件。
* `smtpUsername` 和 `smtpPassword` 是两个必需的变量。
* `smtpAuth` 也是用于发送邮件。
2018-04-21 10:34:37 +08:00
`env` 函数允许我们去获得环境变量,不存在时返回一个回退值。
2018-04-21 10:34:37 +08:00
#### 主函数
2018-04-21 10:34:37 +08:00
```
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)
2018-06-16 10:23:41 +08:00
log.Printf("starting server at %s \n", config.appURL)
2018-04-21 10:34:37 +08:00
log.Fatalf("could not start server: %v\n", http.ListenAndServe(addr, router))
}
```
2018-05-30 15:32:36 +08:00
首先,打开数据库连接。记得要加载驱动。
2018-04-21 10:34:37 +08:00
```
import (
_ "github.com/lib/pq"
)
```
然后,我们创建路由器并定义一些端点。对于无密码流程来说,我们使用两个端点:`/api/passwordless/start` 发送魔法链接,和 `/api/passwordless/verify_redirect` 用 JWT 响应。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
最后,我们启动服务器。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
你可以创建空处理程序和中间件去测试服务器启动。
2018-04-21 10:34:37 +08:00
```
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)
}
}
```
2018-05-30 15:32:36 +08:00
接下来:
2018-04-21 10:34:37 +08:00
```
go build
./passwordless-demo
```
2018-06-16 10:23:41 +08:00
我们在目录中有了一个 “passwordless-demo”但是你的目录中可能与示例不一样`go build` 将创建一个同名的可执行文件。如果你没有关闭前面的 cockroach 节点,并且你正确配置了 `SMTP_USERNAME` 和 `SMTP_PASSWORD` 变量,你将看到命令 `starting server at http://localhost/ ` 没有错误输出。
2018-04-21 10:34:37 +08:00
#### 请求 JSON 的中间件
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
端点需要从请求体中解码 JSON因此要确保请求是 `application/json` 类型。因为它是一个通用的东西,我将它解耦到中间件。
2018-04-21 10:34:37 +08:00
```
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)
}
}
```
2018-05-30 15:32:36 +08:00
实现很容易。首先它从请求头中获得内容的类型,然后检查它是否是以 “application/json” 开始,如果不是则以 `415 Unsupported Media Type` 提前返回。
2018-04-21 10:34:37 +08:00
#### 响应 JSON 的函数
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
以 JSON 响应是非常通用的做法,因此我把它提取到函数中。
2018-04-21 10:34:37 +08:00
```
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)
}
```
2018-05-30 15:32:36 +08:00
首先,对原始类型做一个类型判断,并将它们封装到一个 `map`。然后将它们编组到 JSON设置响应内容类型和状态码并写 JSON。如果 JSON 编组失败,则响应一个内部错误。
2018-04-21 10:34:37 +08:00
#### 响应内部错误的函数
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
`respondInternalError` 是一个响应 `500 Internal Server Error` 的函数,但是也同时将错误输出到控制台。
2018-04-21 10:34:37 +08:00
```
func respondInternalError(w http.ResponseWriter, err error) {
log.Println(err)
respondJSON(w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
```
#### 创建用户的处理程序
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
下面开始编写 `createUser` 处理程序,因为它非常容易并且是 REST 式的。
2018-04-21 10:34:37 +08:00
```
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
```
2018-05-30 15:32:36 +08:00
`User` 类型和 `users` 表相似。
2018-04-21 10:34:37 +08:00
```
var (
rxEmail = regexp.MustCompile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
rxUsername = regexp.MustCompile("^[a-zA-Z][\\w|-]{1,17}$")
)
```
2018-05-30 15:32:36 +08:00
这些正则表达式是分别用于去验证电子邮件和用户名的。这些都很简单,可以根据你的需要随意去适配。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
现在,在 `createUser` 函数内部,我们将开始解码请求体。
2018-04-21 10:34:37 +08:00
```
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondJSON(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
```
2018-05-30 15:32:36 +08:00
我们将使用请求体去创建一个 JSON 解码器来解码出一个用户指针。如果发生错误则返回一个 `400 Bad Request`。不要忘记关闭请求体读取器。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
这是我如何做验证;一个简单的 `map` 并检查如果 `len(errs) != 0`,则使用 `422 Unprocessable Entity` 去返回。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
这个 SQL 查询使用一个给定的 email 和用户名去插入一个新用户,并返回自动生成的 id每个 `$` 将被接下来传递给 `QueryRowContext` 的参数替换掉。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
因为 `users` 表在 `email` 和 `username` 字段上有唯一性约束,因此我将检查 “unique_violation” 错误并返回 `403 Forbidden` 或者返回一个内部错误。
2018-04-21 10:34:37 +08:00
```
respondJSON(w, user, http.StatusCreated)
```
2018-05-30 15:32:36 +08:00
最后使用创建的用户去响应。
2018-04-21 10:34:37 +08:00
#### 无密码验证开始部分的处理程序
2018-04-21 10:34:37 +08:00
```
type PasswordlessStartRequest struct {
Email string `json:"email"`
RedirectURI string `json:"redirectUri"`
}
```
这个结构体含有 `passwordlessStart` 的请求体:希望去登入的用户 email、来自客户端的重定向 URI这个应用中将使用我们的 API`https://frontend.app/callback`。
2018-04-21 10:34:37 +08:00
```
var magicLinkTmpl = template.Must(template.ParseFiles("templates/magic-link.html"))
```
2018-05-30 15:32:36 +08:00
我们将使用 golang 模板引擎去构建邮件,因此需要你在 `templates` 目录中,用如下的内容创建一个 `magic-link.html` 文件:
2018-04-21 10:34:37 +08:00
```
<!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>
```
2018-05-30 15:32:36 +08:00
这个模板是给用户发送魔法链接邮件用的。你可以根据你的需要去随意调整它。
2018-04-21 10:34:37 +08:00
现在, 进入 `passwordlessStart` 函数内部:
2018-04-21 10:34:37 +08:00
```
var input PasswordlessStartRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondJSON(w, err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
```
2018-05-30 15:32:36 +08:00
首先,我们像前面一样解码请求体。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
我们使用 golang 的 URL 解析器去验证重定向 URI检查那个 URI 是否为绝对地址。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
这个 SQL 查询将插入一个验证代码,这个代码通过给定的 email 关联到用户,并且返回一个自动生成的 id。因为有可能会出现用户不存在的情况那样的话子查询可能解析为 `NULL`,这将导致在 `user_id` 字段上因违反 `NOT NULL` 约束而导致失败,因此需要对这种情况进行检查,如果用户不存在,则返回 `404 Not Found` 或者一个内部错误。
2018-04-21 10:34:37 +08:00
```
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()
```
2018-05-30 15:32:36 +08:00
现在,构建魔法链接并设置查询字符串中的 `verification_code` 和 `redirect_uri` 的值。如:`http://localhost/api/passwordless/verify_redirect?verification_code=some_code&redirect_uri=https://frontend.app/callback`。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
我们将得到的魔法链接模板的内容保存到缓冲区中。如果发生错误则返回一个内部错误。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
现在来写给用户发邮件的 `sendMail` 函数。如果发生错误则返回一个内部错误。
2018-04-21 10:34:37 +08:00
```
w.WriteHeader(http.StatusNoContent)
```
2018-05-30 15:32:36 +08:00
最后,设置响应状态码为 `204 No Content`。对于成功的状态码,客户端不需要很多数据。
2018-04-21 10:34:37 +08:00
#### 发送邮件函数
2018-04-21 10:34:37 +08:00
```
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))
}
```
2018-05-30 15:32:36 +08:00
这个函数创建一个基本的 HTML 邮件结构体并使用 SMTP 服务器去发送它。邮件的内容你可以随意定制,我喜欢使用比较简单的内容。
2018-04-21 10:34:37 +08:00
#### 无密码验证重定向的处理程序
2018-04-21 10:34:37 +08:00
```
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即验证代码
2018-04-21 10:34:37 +08:00
现在进入 `passwordlessVerifyRedirect` 函数内部:
2018-04-21 10:34:37 +08:00
```
q := r.URL.Query()
verificationCode := q.Get("verification_code")
redirectURI := q.Get("redirect_uri")
```
2018-05-30 15:32:36 +08:00
`/api/passwordless/verify_redirect` 是一个 `GET` 端点,以便于我们从查询字符串中读取数据。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
类似的验证,我们保存解析后的重定向 URI 到一个 `callback` 变量中。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
这个 SQL 查询通过给定的 id 去删除相应的验证代码,并且确保它创建之后时间不超过 15 分钟,它也返回关联的 `user_id`。如果没有检索到内容,意味着代码不存在或者已过期,我们返回一个响应信息,否则就返回一个内部错误。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
这些是如何去创建 JWT。我们为 JWT 设置一个 60 天的过期值,你也可以设置更短的时间(大约 2 周),并添加一个新端点去刷新令牌,但是不要搞的过于复杂。
2018-04-21 10:34:37 +08:00
```
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()
```
2018-05-30 15:32:36 +08:00
我们去规划重定向;你可使用查询字符串去添加 JWT但是更常见的是使用一个哈希片段。如`https://frontend.app/callback#jwt=token_here&expires_at=some_date`.
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
过期日期可以从 JWT 中提取出来,但是这样做的话,就需要在客户端上实现一个 JWT 库来解码它,因此为了简化,我将它加到这里。
2018-04-21 10:34:37 +08:00
```
http.Redirect(w, r, callback.String(), http.StatusFound)
```
2018-05-30 15:32:36 +08:00
最后我们使用一个 `302 Found` 重定向。
2018-04-21 10:34:37 +08:00
* * *
无密码的流程已经完成。现在需要去写 `getAuthUser` 端点的代码了,它用于获取当前验证用户的信息。你应该还记得,这个端点使用了 `guard` 中间件。
2018-04-21 10:34:37 +08:00
#### 使用 Auth 中间件
2018-04-21 10:34:37 +08:00
在编写 `guard` 中间件之前,我将编写一个不需要验证的分支。目的是,如果没有传递 JWT它将不去验证用户。
2018-04-21 10:34:37 +08:00
```
type ContextKey struct {
Name string
2018-04-21 10:34:37 +08:00
}
var keyAuthUserID = ContextKey{"auth_user_id"}
2018-04-21 10:34:37 +08:00
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 },
)
2018-04-21 10:34:37 +08:00
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` 头中。因此,如果没有提供令牌,我们将直接通过,进入接下来的中间件。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
我们创建一个解析器来解析令牌。如果解析失败则返回 `401 Unauthorized`
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
然后我们从 JWT 中提取出要求的内容,并添加 `Subject`(就是用户 ID到需要的地方。
2018-04-21 10:34:37 +08:00
#### Guard 中间件
2018-04-21 10:34:37 +08:00
```
func guard(next http.HandlerFunc) http.HandlerFunc {
2018-04-21 10:34:37 +08:00
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`,提取成功则继续下一步。
2018-04-21 10:34:37 +08:00
#### 获取 Auth 用户
2018-04-21 10:34:37 +08:00
在 `getAuthUser` 处理程序内部:
2018-04-21 10:34:37 +08:00
```
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`,或者一个内部错误。最后,我们将用这个用户去响应。
2018-04-21 10:34:37 +08:00
#### 获取 User 函数
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
下面你看到的是 `fetchUser` 函数。
2018-04-21 10:34:37 +08:00
```
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
}
```
2018-05-30 15:32:36 +08:00
我将它解耦是因为通过 ID 来获取用户是个常做的事。
2018-04-21 10:34:37 +08:00
* * *
2018-05-30 15:32:36 +08:00
以上就是全部的代码。你可以自己去构建它和测试它。[这里][9] 还有一个 demo 你可以试用一下。
2018-04-21 10:34:37 +08:00
2018-05-30 15:32:36 +08:00
如果你在 mailtrap 上点击之后出现有关 `脚本运行被拦截,因为文档的框架是沙箱化的,并且没有设置 'allow-scripts' 权限` 的问题,你可以尝试右键点击 “在新标签中打开链接“。这样做是安全的,因为邮件内容是 [沙箱化的][10]。我在 `localhost` 上有时也会出现这个问题,但是我认为你一旦以 `https://` 方式部署到服务器上应该不会出现这个问题了。
2018-04-21 10:34:37 +08:00
2018-06-16 10:23:41 +08:00
如果有任何问题,请在我的 [GitHub repo][11] 留言或者提交 PRs
2018-04-21 10:34:37 +08:00
以后,我为这个 API 写了一个客户端作为这篇文章的[第二部分][13]。
2018-04-21 10:34:37 +08:00
--------------------------------------------------------------------------------
via: https://nicolasparada.netlify.com/posts/passwordless-auth-server/
作者:[Nicolás Parada][a]
2018-05-30 15:32:36 +08:00
译者:[qhwdw](https://github.com/qhwdw)
校对:[wxy](https://github.com/wxy)
2018-04-21 10:34:37 +08:00
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出
[a]:https://nicolasparada.netlify.com/
[1]:https://www.cockroachlabs.com/
[2]:https://mailtrap.io/
[3]:https://github.com/lib/pq
[4]:https://github.com/matryer/way
[5]:https://github.com/dgrijalva/jwt-go
[6]:https://golang.org/
[7]:https://golang.org/dl/
[8]:https://www.cockroachlabs.com/docs/stable/install-cockroachdb.html
[9]:https://go-passwordless-demo.herokuapp.com/
[10]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
[11]:https://github.com/nicolasparada/go-passwordless-demo
[12]:https://twitter.com/intent/retweet?tweet_id=986602458716803074
[13]:https://nicolasparada.netlify.com/posts/passwordless-auth-client/