TranslateProject/sources/tech/20180418 Passwordless Auth Server.md
2018-05-15 11:53:39 +08:00

26 KiB
Raw Blame History

Translating by qhwdw Passwordless Auth: Server

Passwordless authentication allows logging in without a password, just an email. Its a more secure way of doing than the classic email/password login.

Ill show you how to code an HTTP API in Go 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: Well use an SQL database called CockroachDB for this. Its much like postgres, but writen in Go.

  • SMTP Server: To send mails well use a third party mailing service. For development well use mailtrap. Mailtrap sends all the mails to its inbox, so you dont have to create multiple fake email accounts to test it.

Install Go from its page and check your installation went ok with go version(1.10.1 atm).

Download CockroachDB from its page, 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. Well 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 well need the following packages:

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; well 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 doesnt 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

Im on a directory called “passwordless-demo”, but if yours is different, go build will create an executable with that name. If you didnt 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

Ill 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 well 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. Dont 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 usernamefields 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"))

Well use the golang template engine to build the mailing so Ill need you to create a magic-link.html file inside a templates directory with a content like so:

<!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>

This template is the mail well send to the user with the magic link. Feel free to style it how you want.

Now, inside passwordlessStart function:

var input PasswordlessStartRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
    respondJSON(w, err.Error(), http.StatusBadRequest)
    return
}
defer r.Body.Close()

First, we decode the request body like before.

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
}

For the redirect URI validation we use the golang URL parser and check that the URI is absolute.

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
}

This SQL query will insert a new verification code associated with a user with the given email and return the auto generated id. Because the user could not exist, that subquery can resolve to NULL which will fail the NOT NULLconstraint on the user_id field so I do a check on that and return with 404 Not Found in case or an internal error otherwise.

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()

Now, I build the magic link and set the verification_code and redirect_uriinside the query string. Ex: http://localhost/api/passwordless/verify_redirect?verification_code=some_code&redirect_uri=https://frontend.app/callback.

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
}

Well get the magic link template content saving it to a buffer. In case of error I return with an internal error.

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
}

To mail the user I make use of sendMail function that Ill code now. In case of error I return with an internal error.

w.WriteHeader(http.StatusNoContent)

Finally, I just set the response status code to 204 No Content. The client doesnt need more data than a success status code.

Send Mail Function

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))
}

This function creates the structure of a basic HTML mail and sends it using the SMTP server. There is a lot of things you can customize of a mail, but I kept it simple.

Passwordless Verify Redirect Handler

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}$")

First, this regular expression is to validate an UUID (the verification code).

Now, inside passwordlessVerifyRedirect function:

q := r.URL.Query()
verificationCode := q.Get("verification_code")
redirectURI := q.Get("redirect_uri")

/api/passwordless/verify_redirect is a GET endpoint so we read data from the query string.

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
}

Pretty similar validation, but we store the parsed redirect URI into a callbackvariable.

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
}

This SQL query deletes a verification code with the given id and makes sure it has been created no more than 15 minutes ago, it also returns the user_idassociated. In case of no rows, means the code didnt exist or it was expired so we respond with that, otherwise an internal error.

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
}

This is how the JWT is created. We set an expiration date for the JWT within 60 days. Maybe you can give it less time (~2 weeks) and add a new endpoint to refresh tokens, but I didnt want to add more complexity.

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()

We plan to redirect; you could use the query string to add the JWT, but Ive seen that a hash fragment is more used. Ex: https://frontend.app/callback#jwt=token_here&expires_at=some_date.

The expiration date could be extracted from the JWT, but then the client will have to implement a JWT library to decode it, so to make the life easier I just added it there too.

http.Redirect(w, r, callback.String(), http.StatusFound)

Finally we just redirect with a 302 Found.


The passwordless flow is completed. Now we just need to code the getAuthUserendpoint which is to get info about the current authenticated user. If you rememeber, this endpoint makes use of authRequired middleware.

With Auth Middleware

Before coding the authRequired middleware, Ill code one that doesnt require authentication. I mean, if no JWT is passed, it just continues without authenticating the user.

type ContextKey int

const (
    keyAuthUserID ContextKey = iota
)

func jwtKeyFunc(*jwt.Token) (interface{}, error) {
    return config.jwtKey, nil
}

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{}, jwtKeyFunc)
        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))
    }
}

The JWT will come in every request inside the “Authorization” header in the form of “Bearer <token_here>”. So if no token is present, we just pass to the next middleware.

We create a parser and parse the token. If fails, we return with 401 Unauthorized.

Then we extract the claims inside the JWT and add the Subject (which is the user ID) to the request context.

Auth Required Middleware

func authRequired(next http.HandlerFunc) http.HandlerFunc {
    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)
    })
}

Now, authRequired will make use of withAuth and will try to extract the authenticated user ID from the request context. If fails, it returns with 401 Unauthorized otherwise continues.

Get Auth User

Inside getAuthUser handler:

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)

First we extract the ID of the authenticated user from the request context, we use that to fetch the user. In case of no row returned, we send a 418 I'm a teapot or an internal error otherwise. Lastly we just respond with the user 😊

Fetch User Function

You saw a fetchUser function there.

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
}

I decoupled it because fetching a user by ID is a common thing.


Thats all the code. Build it and test it yourself. You can try a live demo here.

If you have problems about Blocked script execution because the document's frame is sandboxed and the 'allow-scripts' permission is not set after clicking the magic link on mailtrap, try doing a right click + “Open link in new tab”. This is a security thing where the mail content is sandboxed. I had this problem sometimes on localhost, but I think you should be fine once you deploy the server with https://.

Please leave any issues on the GitHub repo or feel free to send PRs 👍

Ill write a second part for this post coding a client for the API.


via: https://nicolasparada.netlify.com/posts/passwordless-auth-server/

作者:Nicolás Parada 译者:译者ID 校对:校对者ID

本文由 LCTT 原创编译,Linux中国 荣誉推出