mirror of
https://github.com/kataras/iris.git
synced 2025-02-02 15:30:36 +01:00
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)
This commit is contained in:
parent
a412ee55ae
commit
09923183e8
|
@ -9,11 +9,16 @@ import (
|
||||||
|
|
||||||
// UserClaims a custom access claims structure.
|
// UserClaims a custom access claims structure.
|
||||||
type UserClaims struct {
|
type UserClaims struct {
|
||||||
// We could that JWT field to separate the access and refresh token:
|
// In order to separate refresh and access tokens on validation level:
|
||||||
// Issuer string `json:"iss"`
|
// - Set a different Issuer, with a field of: Issuer string `json:"iss"`
|
||||||
// But let's cover the "required" feature too, see below:
|
// - Set the Iris JWT's json tag option "required" on an access token field,
|
||||||
ID string `json:"user_id,required"`
|
// e.g. Username string `json:"username,required"`
|
||||||
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
|
// For refresh token, we will just use the jwt.Claims
|
||||||
|
@ -28,8 +33,8 @@ func main() {
|
||||||
generateTokenPair(ctx, j)
|
generateTokenPair(ctx, j)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Get("/refresh_json", func(ctx iris.Context) {
|
app.Get("/refresh", func(ctx iris.Context) {
|
||||||
refreshTokenFromJSON(ctx, j)
|
refreshToken(ctx, j)
|
||||||
})
|
})
|
||||||
|
|
||||||
protectedAPI := app.Party("/protected")
|
protectedAPI := app.Party("/protected")
|
||||||
|
@ -54,7 +59,9 @@ func main() {
|
||||||
// http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token})
|
// http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token})
|
||||||
// http://localhost:8080/protected?token={access_token} (200)
|
// http://localhost:8080/protected?token={access_token} (200)
|
||||||
// http://localhost:8080/protected?token={refresh_token} (401)
|
// 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")
|
app.Listen(":8080")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,10 +94,14 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) {
|
||||||
ctx.JSON(tokenPair)
|
ctx.JSON(tokenPair)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshTokenFromJSON(ctx iris.Context, j *jwt.JWT) {
|
func refreshToken(ctx iris.Context, j *jwt.JWT) {
|
||||||
var tokenPair jwt.TokenPair
|
var tokenPair jwt.TokenPair
|
||||||
|
|
||||||
// Grab the refresh token from a JSON body (you can let it fetch by URL parameter too but
|
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'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)).
|
// it may contain the access token too (the same response we sent on generateTokenPair)).
|
||||||
err := ctx.ReadJSON(&tokenPair)
|
err := ctx.ReadJSON(&tokenPair)
|
||||||
|
@ -98,9 +109,10 @@ func refreshTokenFromJSON(ctx iris.Context, j *jwt.JWT) {
|
||||||
ctx.StatusCode(iris.StatusBadRequest)
|
ctx.StatusCode(iris.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var refreshClaims jwt.Claims
|
var refreshClaims jwt.Claims
|
||||||
err = j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims)
|
_, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Application().Logger().Debugf("verify refresh token: %v", err)
|
ctx.Application().Logger().Debugf("verify refresh token: %v", err)
|
||||||
ctx.StatusCode(iris.StatusUnauthorized)
|
ctx.StatusCode(iris.StatusUnauthorized)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package jwt
|
||||||
import (
|
import (
|
||||||
"crypto"
|
"crypto"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -338,7 +339,6 @@ func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) {
|
||||||
return "", nErr
|
return "", nErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set expiration, if missing.
|
|
||||||
ExpiryMap(maxAge, c)
|
ExpiryMap(maxAge, c)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -531,22 +531,45 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interfa
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims Claims
|
var (
|
||||||
if err = parsedToken.Claims(j.VerificationKey, dest, &claims); err != nil {
|
claims Claims
|
||||||
|
tokenMaxAger tokenWithMaxAge
|
||||||
|
)
|
||||||
|
|
||||||
|
if err = parsedToken.Claims(j.VerificationKey, dest, &claims, &tokenMaxAger); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectMaxAge := j.MaxAge
|
||||||
|
|
||||||
// Build the Expected value.
|
// Build the Expected value.
|
||||||
expected := Expected{}
|
expected := Expected{}
|
||||||
for _, e := range expectations {
|
for _, e := range expectations {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
// expection can be used as a field validation too (see MeetRequirements).
|
// expection can be used as a field validation too (see MeetRequirements).
|
||||||
if err = e(&expected, dest); err != nil {
|
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
|
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"
|
// For other standard JWT claims fields such as "exp"
|
||||||
// The developer can just add a field of Expiry *NumericDate `json:"exp"`
|
// The developer can just add a field of Expiry *NumericDate `json:"exp"`
|
||||||
// and will be filled by the parsed token automatically.
|
// and will be filled by the parsed token automatically.
|
||||||
|
@ -593,16 +616,37 @@ type TokenPair struct {
|
||||||
RefreshToken string `json:"refresh_token"`
|
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.
|
// TokenPair generates a token pair of access and refresh tokens.
|
||||||
// The first two arguments required for the refresh token
|
// The first two arguments required for the refresh token
|
||||||
// and the last one is the claims for the access token one.
|
// 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) {
|
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)
|
accessToken, err := j.Token(accessClaims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
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 {
|
if err != nil {
|
||||||
return TokenPair{}, nil
|
return TokenPair{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,9 +21,9 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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")
|
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")
|
ErrMissingKey = errors.New("token is missing a required field")
|
||||||
// ErrExpired indicates that token is used after expiry time indicated in exp claim.
|
// ErrExpired indicates that token is used after expiry time indicated in exp claim.
|
||||||
ErrExpired = errors.New("token is expired (exp)")
|
ErrExpired = errors.New("token is expired (exp)")
|
||||||
|
@ -32,8 +32,14 @@ var (
|
||||||
// ErrIssuedInTheFuture indicates that the iat field is in the future.
|
// ErrIssuedInTheFuture indicates that the iat field is in the future.
|
||||||
ErrIssuedInTheFuture = errors.New("token issued in the future (iat)")
|
ErrIssuedInTheFuture = errors.New("token issued in the future (iat)")
|
||||||
// ErrBlocked indicates that the token was not yet expired
|
// 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")
|
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
|
// 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
|
// MeetRequirements protects the custom fields of JWT claims
|
||||||
// based on the json:required tag; `json:"name,required"`.
|
// based on the json:required tag; `json:"name,required"`.
|
||||||
// It accepts the value type.
|
// It accepts the value type.
|
||||||
|
@ -210,3 +226,33 @@ func getRequiredFieldIndexes(i interface{}) (v []int) {
|
||||||
|
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user