無(wú)密碼驗(yàn)證:服務(wù)器 登錄更安全
無(wú)密碼驗(yàn)證可以讓你只輸入一個(gè) email 而無(wú)需輸入密碼即可登入系統(tǒng)。這是一種比傳統(tǒng)的電子郵件/密碼驗(yàn)證方式登入更安全的方法。
下面我將為你展示,如何在 Go 中實(shí)現(xiàn)一個(gè) HTTP API 去提供這種服務(wù)。
流程
- 用戶(hù)輸入他的電子郵件地址。
- 服務(wù)器創(chuàng)建一個(gè)臨時(shí)的一次性使用的代碼(就像一個(gè)臨時(shí)密碼一樣)關(guān)聯(lián)到用戶(hù),然后給用戶(hù)郵箱中發(fā)送一個(gè)“魔法鏈接”。
- 用戶(hù)點(diǎn)擊魔法鏈接。
- 服務(wù)器提取魔法鏈接中的代碼,獲取關(guān)聯(lián)的用戶(hù),并且使用一個(gè)新的 JWT 重定向到客戶(hù)端。
- 在每次有新請(qǐng)求時(shí),客戶(hù)端使用 JWT 去驗(yàn)證用戶(hù)。
必需條件
- 數(shù)據(jù)庫(kù):我們?yōu)檫@個(gè)服務(wù)使用了一個(gè)叫 CockroachDB 的 SQL 數(shù)據(jù)庫(kù)。它非常像 postgres,但它是用 Go 寫(xiě)的。
- SMTP 服務(wù)器:我們將使用一個(gè)第三方的郵件服務(wù)器去發(fā)送郵件。開(kāi)發(fā)的時(shí)我們使用 mailtrap。Mailtrap 發(fā)送所有的郵件到它的收件箱,因此,你在測(cè)試時(shí)不需要?jiǎng)?chuàng)建多個(gè)假郵件帳戶(hù)。
從 Go 的主頁(yè) 上安裝它,然后使用 go version
(1.10.1 atm)命令去檢查它能否正常工作。
從 CockroachDB 的主頁(yè) 上下載它,展開(kāi)它并添加到你的 PATH
變量中。使用 cockroach version
(2.0 atm)命令檢查它能否正常工作。
數(shù)據(jù)庫(kù)模式
現(xiàn)在,我們?cè)?nbsp;GOPATH
目錄下為這個(gè)項(xiàng)目創(chuàng)建一個(gè)目錄,然后使用 cockroach start
啟動(dòng)一個(gè)新的 CockroachDB 節(jié)點(diǎn):
cockroach start --insecure --host 127.0.0.1
它會(huì)輸出一些內(nèi)容,找到 SQL 地址行,它將顯示像 postgresql://root@127.0.0.1:26257?sslmode=disable
這樣的內(nèi)容。稍后我們將使用它去連接到數(shù)據(jù)庫(kù)。
使用如下的內(nèi)容去創(chuàng)建一個(gè) 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');
這個(gè)腳本創(chuàng)建了一個(gè)名為 passwordless_demo
的數(shù)據(jù)庫(kù)、兩個(gè)名為 users
和 verification_codes
的表,以及為了稍后測(cè)試而插入的一些假用戶(hù)。每個(gè)驗(yàn)證代碼都與用戶(hù)關(guān)聯(lián)并保存創(chuàng)建時(shí)間,以用于去檢查驗(yàn)證代碼是否過(guò)期。
在另外的終端中使用 cockroach sql
命令去運(yùn)行這個(gè)腳本:
cat schema.sql | cockroach sql --insecure
環(huán)境配置
需要配置兩個(gè)環(huán)境變量:SMTP_USERNAME
和 SMTP_PASSWORD
,你可以從你的 mailtrap 帳戶(hù)中獲得它們。將在我們的程序中用到它們。
Go 依賴(lài)
我們需要下列的 Go 包:
- github.com/lib/pq:它是 CockroachDB 使用的 postgres 驅(qū)動(dòng)
- github.com/matryer/way: 路由器
- github.com/dgrijalva/jwt-go: JWT 實(shí)現(xiàn)
go get -u github.com/lib/pq
go get -u github.com/matryer/way
go get -u github.com/dgrijalva/jwt-go
代碼
初始化函數(shù)
創(chuàng)建 main.go
并且通過(guò) init
函數(shù)里的環(huán)境變量中取得一些配置來(lái)啟動(dòng)。
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
將去構(gòu)建我們的 “魔法鏈接”。port
將要啟動(dòng)的 HTTP 服務(wù)器。databaseURL
是 CockroachDB 地址,我添加/passwordless_demo
前面的數(shù)據(jù)庫(kù)地址去表示數(shù)據(jù)庫(kù)名字。jwtKey
用于簽名 JWT。smtpAddr
是SMTP_HOST
+SMTP_PORT
的聯(lián)合;我們將使用它去發(fā)送郵件。smtpUsername
和smtpPassword
是兩個(gè)必需的變量。smtpAuth
也是用于發(fā)送郵件。
env
函數(shù)允許我們?nèi)カ@得環(huán)境變量,不存在時(shí)返回一個(gè)回退值。
主函數(shù)
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