From 09923183e815a3079c2a0a53fdeca312f9c02aa2 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 14:42:19 +0300 Subject: [PATCH] add an extra security layer on JWT and able to separate access from refresh tokens without any end-developer action on the claims payload (e.g. set a different issuer) --- _examples/auth/jwt/refresh-token/main.go | 46 +++++++++++++-------- middleware/jwt/jwt.go | 52 ++++++++++++++++++++++-- middleware/jwt/validation.go | 52 ++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index 4f41c22d..7c0629b5 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -9,11 +9,16 @@ import ( // UserClaims a custom access claims structure. type UserClaims struct { - // We could that JWT field to separate the access and refresh token: - // Issuer string `json:"iss"` - // But let's cover the "required" feature too, see below: - ID string `json:"user_id,required"` - Username string `json:"username,required"` + // In order to separate refresh and access tokens on validation level: + // - Set a different Issuer, with a field of: Issuer string `json:"iss"` + // - Set the Iris JWT's json tag option "required" on an access token field, + // e.g. Username string `json:"username,required"` + // - Let the middleware validate the correct one based on the given MaxAge, + // which should be different between refresh and max age (refersh should be bigger) + // by setting the `jwt.ExpectRefreshToken` on Verify/VerifyToken/VerifyTokenString + // (see `refreshToken` function below) + ID string `json:"user_id"` + Username string `json:"username"` } // For refresh token, we will just use the jwt.Claims @@ -28,8 +33,8 @@ func main() { generateTokenPair(ctx, j) }) - app.Get("/refresh_json", func(ctx iris.Context) { - refreshTokenFromJSON(ctx, j) + app.Get("/refresh", func(ctx iris.Context) { + refreshToken(ctx, j) }) protectedAPI := app.Party("/protected") @@ -54,7 +59,9 @@ func main() { // http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token}) // http://localhost:8080/protected?token={access_token} (200) // http://localhost:8080/protected?token={refresh_token} (401) - // http://localhost:8080/refresh_json (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // http://localhost:8080/refresh?token={refresh_token} + // OR (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // http://localhost:8080/refresh?token={access_token} (401) app.Listen(":8080") } @@ -87,20 +94,25 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) { ctx.JSON(tokenPair) } -func refreshTokenFromJSON(ctx iris.Context, j *jwt.JWT) { +func refreshToken(ctx iris.Context, j *jwt.JWT) { var tokenPair jwt.TokenPair - // Grab the refresh token from a JSON body (you can let it fetch by URL parameter too but - // it's common practice that you read it from a json body as - // it may contain the access token too (the same response we sent on generateTokenPair)). - err := ctx.ReadJSON(&tokenPair) - if err != nil { - ctx.StatusCode(iris.StatusBadRequest) - return + if token := ctx.URLParam("token"); token != "" { + // Grab the refresh token from the url argument. + tokenPair.RefreshToken = token + } else { + // Otherwise grab the refresh token from a JSON body (you can let it fetch by URL parameter too but + // it's common practice that you read it from a json body as + // it may contain the access token too (the same response we sent on generateTokenPair)). + err := ctx.ReadJSON(&tokenPair) + if err != nil { + ctx.StatusCode(iris.StatusBadRequest) + return + } } var refreshClaims jwt.Claims - err = j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims) + _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) if err != nil { ctx.Application().Logger().Debugf("verify refresh token: %v", err) ctx.StatusCode(iris.StatusUnauthorized) diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 6cc8c167..25154bd3 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -3,6 +3,7 @@ package jwt import ( "crypto" "encoding/json" + "fmt" "os" "strings" "time" @@ -338,7 +339,6 @@ func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { return "", nErr } - // Set expiration, if missing. ExpiryMap(maxAge, c) var ( @@ -531,22 +531,45 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interfa return nil, err } - var claims Claims - if err = parsedToken.Claims(j.VerificationKey, dest, &claims); err != nil { + var ( + claims Claims + tokenMaxAger tokenWithMaxAge + ) + + if err = parsedToken.Claims(j.VerificationKey, dest, &claims, &tokenMaxAger); err != nil { return nil, err } + expectMaxAge := j.MaxAge + // Build the Expected value. expected := Expected{} for _, e := range expectations { if e != nil { // expection can be used as a field validation too (see MeetRequirements). if err = e(&expected, dest); err != nil { + if err == ErrExpectRefreshToken { + if tokenMaxAger.MaxAge > 0 { + // If max age exists, grab it and compare it later. + // Otherwise fire the ErrExpectRefreshToken. + expectMaxAge = tokenMaxAger.MaxAge + continue + } + } return nil, err } } } + gotMaxAge := getMaxAge(claims) + if !compareMaxAge(expectMaxAge, gotMaxAge) { + // Additional check to automatically invalidate + // any previous jwt maxAge setting change. + // In-short, if the time.Now().Add j.MaxAge + // does not match the "iat" (issued at) then we invalidate the token. + return nil, ErrInvalidMaxAge + } + // For other standard JWT claims fields such as "exp" // The developer can just add a field of Expiry *NumericDate `json:"exp"` // and will be filled by the parsed token automatically. @@ -593,16 +616,37 @@ type TokenPair struct { RefreshToken string `json:"refresh_token"` } +type tokenWithMaxAge struct { + // Useful to separate access from refresh tokens. + // Can be used to by-pass the internal check of expected + // MaxAge setting to match the token's received max age too. + MaxAge time.Duration `json:"tokenMaxAge"` +} + // TokenPair generates a token pair of access and refresh tokens. // The first two arguments required for the refresh token // and the last one is the claims for the access token one. func (j *JWT) TokenPair(refreshMaxAge time.Duration, refreshClaims interface{}, accessClaims interface{}) (TokenPair, error) { + if refreshMaxAge <= j.MaxAge { + return TokenPair{}, fmt.Errorf("refresh max age should be bigger than access token's one[%d - %d]", refreshMaxAge, j.MaxAge) + } + accessToken, err := j.Token(accessClaims) if err != nil { return TokenPair{}, err } - refreshToken, err := j.token(refreshMaxAge, refreshClaims) + c, err := normalize(refreshClaims) + if err != nil { + return TokenPair{}, err + } + if c == nil { + c = make(context.Map) + } + // need to validate against its value instead of the setting's one (see `VerifyTokenString`). + c["tokenMaxAge"] = refreshMaxAge + + refreshToken, err := j.token(refreshMaxAge, c) if err != nil { return TokenPair{}, nil } diff --git a/middleware/jwt/validation.go b/middleware/jwt/validation.go index 9e8ffd62..9b5b6964 100644 --- a/middleware/jwt/validation.go +++ b/middleware/jwt/validation.go @@ -21,9 +21,9 @@ const ( ) var ( - // ErrMissing when token cannot be extracted from the request. + // ErrMissing when token cannot be extracted from the request (custm error). ErrMissing = errors.New("token is missing") - // ErrMissingKey when token does not contain a required JSON field. + // ErrMissingKey when token does not contain a required JSON field (custom error). ErrMissingKey = errors.New("token is missing a required field") // ErrExpired indicates that token is used after expiry time indicated in exp claim. ErrExpired = errors.New("token is expired (exp)") @@ -32,8 +32,14 @@ var ( // ErrIssuedInTheFuture indicates that the iat field is in the future. ErrIssuedInTheFuture = errors.New("token issued in the future (iat)") // ErrBlocked indicates that the token was not yet expired - // but was blocked by the server's Blocklist. + // but was blocked by the server's Blocklist (custom error). ErrBlocked = errors.New("token is blocked") + // ErrInvalidMaxAge indicates that the token is using a different + // max age than the configurated one ( custom error). + ErrInvalidMaxAge = errors.New("token contains invalid max age") + // ErrExpectRefreshToken indicates that the retrieved token + // was not a refresh token one when `ExpectRefreshToken` is set (custome rror). + ErrExpectRefreshToken = errors.New("expect refresh token") ) // Expectation option to provide @@ -81,6 +87,16 @@ func ExpectAudience(audience ...string) Expectation { } } +// ExpectRefreshToken SHOULD be passed when a token should be verified +// based on the expiration set by `TokenPair` method instead of the JWT instance's MaxAge setting. +// Useful to validate Refresh Tokens and invalidate Access ones when refresh API is fired, +// if that option is missing then refresh tokens are invalidated when an access token was expected. +// +// Usage: +// var refreshClaims jwt.Claims +// _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) +func ExpectRefreshToken(e *Expected, _ interface{}) error { return ErrExpectRefreshToken } + // MeetRequirements protects the custom fields of JWT claims // based on the json:required tag; `json:"name,required"`. // It accepts the value type. @@ -210,3 +226,33 @@ func getRequiredFieldIndexes(i interface{}) (v []int) { return } + +// getMaxAge returns the result of expiry-issued at. +// Note that if in JWT MaxAge's was set to a value like: 3.5 seconds +// this will return 3 on token retreival. Of course this is not a problem +// in real world apps as they don't invalidate tokens in seconds +// based on a division result like 2/7. +func getMaxAge(claims Claims) time.Duration { + if issuedAt := claims.IssuedAt.Time(); !issuedAt.IsZero() { + gotMaxAge := claims.Expiry.Time().Sub(issuedAt) + return gotMaxAge + } + + return 0 +} + +func compareMaxAge(expected, got time.Duration) bool { + if expected == got { + return true + } + + // got is int64, maybe rounded, but the max age setting is precise, may be a float result + // e.g. the result of a division 2/7=3.5, + // try to validate by round of second so similar/or equal max age setting are considered valid. + min, max := expected-time.Second, expected+time.Second + if got < min || got > max { + return false + } + + return true +}