From c6c1d0d160700d968b45083907e56b4dacd88e02 Mon Sep 17 00:00:00 2001 From: UnicodingUnicorn <7555ic@gmail.com> Date: Sun, 24 Feb 2019 12:01:02 +0800 Subject: [PATCH] SMS login --- .env | 6 ++ README.md | 82 +++++++++++++++++++-- go.mod | 7 ++ go.sum | 36 ++++++++++ main.go | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 332 insertions(+), 9 deletions(-) diff --git a/.env b/.env index e70474d..5cb7305 100644 --- a/.env +++ b/.env @@ -1,2 +1,8 @@ LISTEN=:8080 SECRET=secret +POSTGRES=postgresql://root@localhost:26257/core?sslmode=disable +REDIS=:6379 +TTL=120s +MESSAGING_SID=MG19d18fafcff1f3f34dff04c5b04c0699 +TWILIO_SID=AC22ea3eea85e5108a96b947aea8ab1320 +TWILIO_TOKEN=fb23fa1a1564aa9f62a7a3117f07b3a0 diff --git a/README.md b/README.md index efc55ce..c3944c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # backend-login -Beep backend handling login. For now, just a POST endpoint returning a JWT. In the furture, SMS-based perpetual login. +Beep backend handling login. Call `/init` and then `/verify` in sequence. `/login` is legacy to provide an easy source of tokens for testing, and will be removed someday™. ## Environment variables @@ -11,24 +11,92 @@ Supply environment variables by either exporting them or editing ```.env```. | LISTEN | Host and port number to listen on | :8080 | | SECRET | JWT secret | secret | -## API (temporary) +## API + +### Init Auth + +``` +POST /init +``` + +Kick off SMS verification process. + +#### Body + +| Name | Type | Description | +| ---- | ---- | ----------- | +| phone_number | String | Verifying phone number in format `<8 digits>`. | + +#### Success (200 OK) + +A nonce, to be used for `/verify` to add additional entropy. + +#### Errors + +| Code | Description | +| ---- | ----------- | +| 400 | Error parsing body/phone_number is not a valid phone number | +| 500 | Error generating nonce/Making request to Twilio SMS | + +--- + +### Verify Code + +``` +POST /verify +``` + +Second half of the verification process, verifying the code and returning a JWT. If the user does not exist in the database, a blank one is created. + +#### Body + +| Name | Type | Description | +| ---- | ---- | ----------- | +| code | String | Verification code received by SMS. | +| nonce | String | Nonce returned by `/init`. | +| clientid | String | ID unique to device, e.g. MAC Address | + +#### Success (200 OK) + +JWT token. + +```json +{ + "userid": "", + "clientid": "" +} +``` + +#### Errors + +| Code | Description | +| ---- | ----------- | +| 400 | Error parsing body | +| 404 | Code with nonce supplied was not found | +| 500 | Error retrieving record from Redis/querying postgres/creating user ID/generating token | + +--- + +### Create Token (temporary) ``` POST /login ``` -### Body +Just a simple little endpoint to get a valid token without having to jump through the (expensive) hoops of SMS Authentication. + +#### Body | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| user | String | User's ID. | ✓ | -| device | String | Device's ID. Must be unique to the device. I suggest something based on MAC address. | ✓ | +| userid | String | User's ID. | ✓ | +| clientid | String | Device's ID. Must be unique to the device. I suggest something based on MAC address. | ✓ | -### Success (200 OK) +#### Success (200 OK) JWT token. -### Errors +#### Errors | Code | Description | | ---- | ----------- | diff --git a/go.mod b/go.mod index ca933f3..d5702ae 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,13 @@ module login require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/go-redis/redis v6.15.1+incompatible github.com/joho/godotenv v1.3.0 github.com/julienschmidt/httprouter v1.2.0 + github.com/lib/pq v1.0.0 + github.com/onsi/ginkgo v1.7.0 // indirect + github.com/onsi/gomega v1.4.3 // indirect + github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect + github.com/ttacon/libphonenumber v1.0.1 + golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 // indirect ) diff --git a/go.sum b/go.sum index 2580ace..e29ba54 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,42 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-redis/redis v6.15.1+incompatible h1:BZ9s4/vHrIqwOb0OPtTQ5uABxETJ3NRuUNoSUurnkew= +github.com/go-redis/redis v6.15.1+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 h1:5u+EJUQiosu3JFX0XS0qTf5FznsMOzTjGqavBGuCbo0= +github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2/go.mod h1:4kyMkleCiLkgY6z8gK5BkI01ChBtxR0ro3I1ZDcGM3w= +github.com/ttacon/libphonenumber v1.0.1 h1:sYxYtW16xbklwUA3tJjTGMInEMLYClJjiIX4b7t5Ip0= +github.com/ttacon/libphonenumber v1.0.1/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkUl+yR4OAxyEg/M= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index f76e9fa..cb6053e 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,40 @@ package main import ( + "crypto/rand" + "crypto/tls" + "database/sql" + "encoding/hex" "encoding/json" + "fmt" "log" + "math/big" "net/http" + "net/url" "os" + "strings" + "time" "github.com/joho/godotenv" "github.com/julienschmidt/httprouter" "github.com/dgrijalva/jwt-go" + "github.com/go-redis/redis" + "github.com/ttacon/libphonenumber" + _ "github.com/lib/pq" ) var listen string +var postgres string +var redisHost string var secret []byte +var ttl time.Duration +var messagingSID string + +var twilioSID string +var twilioToken string + +var db *sql.DB +var redisClient *redis.Client func main() { // Load .env @@ -21,20 +43,204 @@ func main() { log.Fatal("Error loading .env file") } listen = os.Getenv("LISTEN") - s := os.Getenv("SECRET") + secret = []byte(os.Getenv("SECRET")) + postgres = os.Getenv("POSTGRES") + redisHost = os.Getenv("REDIS") - secret = []byte(s) + ttl, err = time.ParseDuration(os.Getenv("TTL")) + if err != nil { + log.Fatal("Error parsing ttl") + } + + messagingSID = os.Getenv("MESSAGING_SID") + twilioSID = os.Getenv("TWILIO_SID") + twilioToken = os.Getenv("TWILIO_TOKEN") + + // Postgres + log.Printf("connecting to postgres %s", postgres) + db, err = sql.Open("postgres", postgres) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // Redis + redisClient = redis.NewClient(&redis.Options{ + Addr: redisHost, + Password: "", + DB: 1, + }) // Routes router := httprouter.New() router.POST("/login", Login); + router.POST("/init", InitRequest) + router.POST("/verify", VerifyCode) // Start server log.Printf("starting server on %s", listen) log.Fatal(http.ListenAndServe(listen, router)) } +func ParsePhone(phone string) (string, error) { + num, err := libphonenumber.Parse(phone, "") + if err != nil { + return "", err + } + return libphonenumber.Format(num, libphonenumber.INTERNATIONAL), nil +} + +func RandomHex() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + return hex.EncodeToString(b), err +} + +type InitRequestBody struct { + PhoneNumber string `json:"phone_number"` +} +func InitRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + // Get request body + req := InitRequestBody{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&req) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Make sure phone number is legitimate + phone, err := ParsePhone(req.PhoneNumber) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Generate OTP code + c, err := rand.Int(rand.Reader, big.NewInt(1000000)) + code := fmt.Sprintf("%06d", c) + + // Generate nonce + b := make([]byte, 16) + _, err = rand.Read(b) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + bytes := hex.EncodeToString(b) + + // Set code-nonce pair in redis first + redisClient.Set(code + "nonce", bytes, ttl) + // Set code-phone_number pair + redisClient.Set(code + "phone", phone, ttl) + + // Send SMS via Twilio + data := url.Values {} + data.Set("MessagingServiceSid", messagingSID) + data.Set("To", phone) + data.Set("Body", fmt.Sprintf("Your OTP for Beep is %s", code)) + + url := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", twilioSID) + twilioReq, err := http.NewRequest("POST", url, strings.NewReader(data.Encode())) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + twilioReq.SetBasicAuth(twilioSID, twilioToken) + twilioReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + // Twilio uses self-signed certs + transport := &http.Transport { + TLSClientConfig: &tls.Config{ InsecureSkipVerify: true }, + } + client := &http.Client{ Transport: transport } + resp, err := client.Do(twilioReq) + + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // Return nonce + w.Write([]byte(bytes)) +} + +type VerifyRequestBody struct { + Code string `json:"code"` + Nonce string `json:"nonce"` + ClientId string `json:"clientid"` +} +func VerifyCode(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + // Get request body + req := VerifyRequestBody{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&req) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Get nonce + storedNonce, err := redisClient.Get(req.Code + "nonce").Result() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if req.Nonce != storedNonce { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + // Get stored phone number + phoneNumber, err := redisClient.Get(req.Code + "phone").Result() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // Generate (potential) User ID + userHex, err := RandomHex() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + userIDPotential := "u-" + userHex + + // Check for existing user + var userID string + err = db.QueryRow(` + INSERT INTO "user" (id, first_name, last_name, phone_number) + VALUES ($1, '', '', $2) + ON CONFLICT(phone_number) + DO UPDATE SET phone_number=EXCLUDED.phone_number + RETURNING id + `, userIDPotential, phoneNumber).Scan(&userID) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + log.Println(err) + return + } + + // Generate JWT + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims { + "userid": userID, + "clientid": req.ClientId, + }) + + tokenString, err := token.SignedString(secret) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + w.Write([]byte(tokenString)) +} + type LoginData struct { ID string `json:"userid"` Client string `json:"clientid"`