Translating by qhwdw Passwordless Auth: Server ============================================================ Passwordless authentication allows logging in without a password, just an email. It’s a more secure way of doing than the classic email/password login. I’ll show you how to code an HTTP API in [Go][6] that provides this service. ### Flow * User inputs his email. * Server creates a temporal on-time-use code associated with the user (like a temporal password) and mails it to the user in form of a “magic link”. * User clicks the magic link. * Server extract the code from the magic link, fetch the user associated and redirects to the client with a new JWT. * Client will use the JWT in every new request to authenticate the user. ### Requisites * Database: We’ll use an SQL database called [CockroachDB][1] for this. It’s much like postgres, but writen in Go. * SMTP Server: To send mails we’ll use a third party mailing service. For development we’ll use [mailtrap][2]. Mailtrap sends all the mails to it’s inbox, so you don’t have to create multiple fake email accounts to test it. Install Go from [it’s page][7] and check your installation went ok with `go version`(1.10.1 atm). Download CockroachDB from [it’s page][8], extract it and add it to your `PATH`. Check that all went ok with `cockroach version` (2.0 atm). ### Database Schema Now, create a new directory for the project inside `GOPATH` and start a new CockroachDB node with `cockroach start`: ``` cockroach start --insecure --host 127.0.0.1 ``` It will print some things, but check the SQL address line, it should said something like `postgresql://root@127.0.0.1:26257?sslmode=disable`. We’ll use this to connect to the database later. Create a `schema.sql` file with the following content. ``` 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'); ``` This script creates a database `passwordless_demo`, two tables: `users` and `verification_codes`, and inserts a fake user just to test it later. Each verification code is associated with a user and stores the creation date, useful to check if the code is expired or not. To execute this script use `cockroach sql` in other terminal: ``` cat schema.sql | cockroach sql --insecure ``` ### Environment Configuration I want you to set two environment variables: `SMTP_USERNAME` and `SMTP_PASSWORD` that you can get from your mailtrap account. These two will be required by our program. ### Go Dependencies For Go we’ll need the following packages: * [github.com/lib/pq][3]: Postgres driver which CockroachDB uses. * [github.com/matryer/way][4]: Router. * [github.com/dgrijalva/jwt-go][5]: JWT implementation. ``` go get -u github.com/lib/pq go get -u github.com/matryer/way go get -u github.com/dgrijalva/jwt-go ``` ### Coding ### Init Function Create the `main.go` and start by getting some configuration from the environment inside the `init` function. ``` 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` will allow us to build the “magic link”. * `port` in which the HTTP server will start. * `databaseURL` is the CockroachDB address, I added `/passwordless_demo` to the previous address to indicate the database name. * `jwtKey` used to sign JWTs. * `smtpAddr` is a join of `SMTP_HOST` + `SMTP_PORT`; we’ll use it to to send mails. * `smtpUsername` and `smtpPassword` are the two required vars. * `smtpAuth` is also used to send mails. The `env` function allow us to get an environment variable with a fallback value in case it doesn’t exist. ### Main Function ``` 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)) } ``` First, it opens a database connection. Remember to load the driver. ``` import ( _ "github.com/lib/pq" ) ``` Then, we create the router and define some endpoints. For the passwordless flow we use two endpoints: `/api/passwordless/start` mails the magic link and `/api/passwordless/verify_redirect` respond with the JWT. Finally, we start the server. You can create empty handlers and middlewares to test that the server starts. ``` 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) } } ``` Now: ``` go build ./passwordless-demo ``` I’m on a directory called “passwordless-demo”, but if yours is different, `go build` will create an executable with that name. If you didn’t close the previous cockroach node and you setted `SMTP_USERNAME` and `SMTP_PASSWORD` vars correctly, you should see `starting server at http://localhost/ 🚀` without errors. ### JSON Required Middleware Endpoints that need to decode JSON from the request body need to make sure the request is of type `application/json`. Because that is a common thing, I decoupled it to a middleware. ``` 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) } } ``` As easy as that. First it gets the request content type from the headers, then check if it starts with “application/json”, otherwise it early return with `415 Unsupported Media Type`. ### Respond JSON Function Responding with JSON is also a common thing so I extracted it to a function. ``` 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) } ``` First, it does a type assertion for primitive types to wrap they in a `map`. Then it marshalls to JSON, sets the response content type and status code, and writes the JSON. In case the JSON marshalling fails, it respond with an internal error. ### Respond Internal Error Function `respondInternalError` is a funcion that respond with `500 Internal Server Error`, but it also logs the error to the console. ``` func respondInternalError(w http.ResponseWriter, err error) { log.Println(err) respondJSON(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } ``` ### Create User Handler I’ll start coding the `createUser` handler because is the more easy and REST-ish. ``` type User struct { ID string `json:"id"` Email string `json:"email"` Username string `json:"username"` } ``` The `User` type is just like the `users` table. ``` var ( rxEmail = regexp.MustCompile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$") rxUsername = regexp.MustCompile("^[a-zA-Z][\\w|-]{1,17}$") ) ``` These regular expressions are to validate email and username respectively. These are very basic, feel free to adapt they as you need. Now, **inside** `createUser` function we’ll start by decoding the request body. ``` var user User if err := json.NewDecoder(r.Body).Decode(&user); err != nil { respondJSON(w, err.Error(), http.StatusBadRequest) return } defer r.Body.Close() ``` We create a JSON decoder using the request body and decode to a user pointer. In case of error we return with a `400 Bad Request`. Don’t forget to close the body reader. ``` 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 } ``` This is how I make validation; a simple `map` and check if `len(errs) != 0` to return with `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 } ``` This SQL query inserts a new user with the given email and username, and returns the auto generated id. Each `$` will be replaced by the next arguments passed to `QueryRowContext`. Because the `users` table had unique constraints on the `email` and `username`fields I check for the “unique_violation” error to return with `403 Forbidden` or I return with an internal error. ``` respondJSON(w, user, http.StatusCreated) ``` Finally I just respond with the created user. ### Passwordless Start Handler ``` type PasswordlessStartRequest struct { Email string `json:"email"` RedirectURI string `json:"redirectUri"` } ``` This struct holds the `passwordlessStart` request body. The email of the user who wants to log in. The redirect URI comes from the client (the app that will use our API) ex: `https://frontend.app/callback`. ``` var magicLinkTmpl = template.Must(template.ParseFiles("templates/magic-link.html")) ``` We’ll use the golang template engine to build the mailing so I’ll need you to create a `magic-link.html` file inside a `templates` directory with a content like so: ```