Authentication with JSON Web Tokens
In this appendix we’re going to switch the authentication process for our API to use JSON Web Tokens (JWTs).
As we briefly explained earlier in the book, JWTs are a type of stateless token. They contain a set of claims which are signed (using either a symmetric or asymmetric signing algorithm) and then encoded using base64. In our case, we’ll use JWTs to carry a subject claim containing the ID of an authenticated user.
There are a few different packages available which make working with JWTs relatively simple in Go. In most cases the pascaldekloe/jwt package is a good choice — it has a clear and simple API, and is designed to avoid a couple of the major JWT security vulnerabilities by default.
If you want to follow along, please install it like so:
$ go get github.com/pascaldekloe/jwt@v1
One of the first things that you need to consider when using JWTs is the choice of signing algorithm.
If your JWT is going to be consumed by a different application to the one that created it, you should normally use an asymmetric-key algorithm like ECDSA or RSA. The ‘creating’ application uses its private key to sign the JWT, and the ‘consuming’ application uses the corresponding public key to verify the signature.
Whereas if your JWT is going to be consumed by the same application that created it, then the appropriate choice is a (simpler and faster) symmetric-key algorithm like HMAC-SHA256 with a random secret key. This is what we’ll use for our API.
So, to get authentication with JWTs working, you’ll first need to add a secret key for signing the JWTs to your .envrc file. For example:
export GREENLIGHT_DB_DSN=postgres://greenlight:pa55word@localhost/greenlight export JWT_SECRET=pei3einoh0Beem6uM6Ungohn2heiv5lah1ael4joopie5JaigeikoozaoTew2Eh6
And then you’ll need to update your Makefile to pass in the secret key as a command-line flag when starting the application, like so:
... # ==================================================================================== # # DEVELOPMENT # ==================================================================================== # ## run/api: run the cmd/api application .PHONY: run/api run/api: @go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} -jwt-secret=${JWT_SECRET} ...
Next you’ll need to edit the cmd/api/main.go file so that it parses the JWT secret from the command-line flag into the config struct:
package main ... type config struct { port int env string db struct { dsn string maxOpenConns int maxIdleConns int maxIdleTime time.Duration } limiter struct { enabled bool rps float64 burst int } smtp struct { host string port int username string password string sender string } cors struct { trustedOrigins []string } jwt struct { secret string // Add a new field to store the JWT signing secret. } } ... func main() { var cfg config ... // Parse the JWT signing secret from the command-line flag. Notice that we leave the // default value as the empty string if no flag is provided. flag.StringVar(&cfg.jwt.secret, "jwt-secret", "", "JWT secret") displayVersion := flag.Bool("version", false, "Display version and exit") flag.Parse() ... } ...
And once that’s done, you can change the createAuthenticationTokenHandler() so that it generates and sends a JWT instead of a stateful token. Like so:
package main import ( "errors" "net/http" "strconv" // New import "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/validator" "github.com/pascaldekloe/jwt" // New import ) func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` Password string `json:"password"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } v := validator.New() data.ValidateEmail(v, input.Email) data.ValidatePasswordPlaintext(v, input.Password) if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } user, err := app.models.Users.GetByEmail(input.Email) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.invalidCredentialsResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } match, err := user.Password.Matches(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } if !match { app.invalidCredentialsResponse(w, r) return } // Create a JWT claims struct containing the user ID as the subject, with an issued // time of now and validity window of the next 24 hours. We also set the issuer and // audience to a unique identifier for our application. var claims jwt.Claims claims.Subject = strconv.FormatInt(user.ID, 10) claims.Issued = jwt.NewNumericTime(time.Now()) claims.NotBefore = jwt.NewNumericTime(time.Now()) claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour)) claims.Issuer = "greenlight.alexedwards.net" claims.Audiences = []string{"greenlight.alexedwards.net"} // Sign the JWT claims using the HMAC-SHA256 algorithm and the secret key from the // application config. This returns a []byte slice containing the JWT as a base64- // encoded string. jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.jwt.secret)) if err != nil { app.serverErrorResponse(w, r, err) return } // Convert the []byte slice to a string and return it in a JSON response. err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": string(jwtBytes)}, nil) if err != nil { app.serverErrorResponse(w, r, err) } } ...
Go ahead and vendor the new github.com/pascaldekloe/jwt dependency and run the API like so:
$ make vendor $ make run/api
Then when you make a request to the POST /v1/tokens/authentication endpoint with a valid email address and password, you should now get a response containing a JWT like this (line breaks added for readability):
$ curl -X POST -d '{"email": "faith@example.com", "password": "pa55word"}' localhost:4000/v1/tokens/authentication
{
"authentication_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ2h0LmFsZXhlZHdhcm
RzLm5ldCIsInN1YiI6IjciLCJhdWQiOlsiZ3JlZW5saWdodC5hbGV4ZWR3YXJkcy5uZXQiXSwiZXhwIjoxNj
E4OTM4MjY0LjgxOTIwNSwibmJmIjoxNjE4ODUxODY0LjgxOTIwNSwiaWF0IjoxNjE4ODUxODY0LjgxOTIwND
h9.zNK1bJPl5rlr_YvjyOXuimJwaC3KgPqmW2M1u5RvgeA"
}
If you’re curious, you can decode the base64-encoded JWT data. You should see that the content of the claims matches the information that you would expect, similar to this:
{"alg":"HS256"}{"iss":"greenlight.alexedwards.net","sub":"7","aud":["greenlight.alexedwards.net"],
"exp":1618938264.819205,"nbf":1618851864.819205,"iat":1618851864.8192048}...
Next you’ll need to update the authenticate() middleware to accept JWTs in an Authorization: Bearer <jwt> header, verify the JWT, and extract the user ID from the subject claim.
When we say “verify the JWT”, what we actually mean is the following four things:
- Check that the signature of the JWT matches the contents of the JWT, given our secret key. This will confirm that the token hasn’t been altered by the client.
- Check that the current time is between the “not before” and “expires” times for the JWT.
- Check that the JWT “issuer” is
"greenlight.alexedwards.net". - Check that
"greenlight.alexedwards.net"is in the JWT “audiences”.
Go ahead and update the authenticate() middleware as follows:
package main import ( "errors" "expvar" "fmt" "net/http" "strconv" "strings" "sync" "time" "greenlight.alexedwards.net/internal/data" "github.com/pascaldekloe/jwt" // New import "github.com/tomasen/realip" "golang.org/x/time/rate" ) ... func (app *application) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Vary", "Authorization") authorizationHeader := r.Header.Get("Authorization") if authorizationHeader == "" { r = app.contextSetUser(r, data.AnonymousUser) next.ServeHTTP(w, r) return } headerParts := strings.Split(authorizationHeader, " ") if len(headerParts) != 2 || headerParts[0] != "Bearer" { app.invalidAuthenticationTokenResponse(w, r) return } token := headerParts[1] // Parse the JWT and extract the claims. This will return an error if the JWT // content doesn't match the signature (i.e. the token has been tampered with) // or the algorithm isn't valid. claims, err := jwt.HMACCheck([]byte(token), []byte(app.config.jwt.secret)) if err != nil { app.invalidAuthenticationTokenResponse(w, r) return } // Check if the JWT is still valid at this moment in time. if !claims.Valid(time.Now()) { app.invalidAuthenticationTokenResponse(w, r) return } // Check that the issuer is our application. if claims.Issuer != "greenlight.alexedwards.net" { app.invalidAuthenticationTokenResponse(w, r) return } // Check that our application is in the expected audiences for the JWT. if !claims.AcceptAudience("greenlight.alexedwards.net") { app.invalidAuthenticationTokenResponse(w, r) return } // At this point, we know that the JWT is all OK and we can trust the data in // it. We extract the user ID from the claims subject and convert it from a // string into an int64. userID, err := strconv.ParseInt(claims.Subject, 10, 64) if err != nil { app.serverErrorResponse(w, r, err) return } // Lookup the user record from the database. user, err := app.models.Users.Get(userID) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.invalidAuthenticationTokenResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } // Add the user record to the request context and continue as normal. r = app.contextSetUser(r, user) next.ServeHTTP(w, r) }) }
Lastly, to get this working, you’ll need to create a new UserModel.Get() method to retrieve the user details from the database based on their ID.
package data ... func (m UserModel) Get(id int64) (*User, error) { query := ` SELECT id, created_at, name, email, password_hash, activated, version FROM users WHERE id = $1` var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.CreatedAt, &user.Name, &user.Email, &user.Password.hash, &user.Activated, &user.Version, ) if err != nil { switch { case errors.Is(err, sql.ErrNoRows): return nil, ErrRecordNotFound default: return nil, err } } return &user, nil }
You should now be able to make a request to one of the protected endpoints, and it will only succeed if your request contains a valid JWT in the Authorization header. For example:
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ..." localhost:4000/v1/movies/2
{
"movie": {
"id": 2,
"title": "Black Panther",
"year": 2018,
"runtime": "134 mins",
"genres": [
"sci-fi",
"action",
"adventure"
],
"version": 2
}
}
$ curl -H "Authorization: Bearer INVALID" localhost:4000/v1/movies/2
{
"error": "invalid or missing authentication token"
}
If you’re planning to put a system into production which uses JWTs, I also recommend spending some time to read and fully understand the following two articles: