From 8eea0296a740df2585015cade4f44de65b209ade Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Fri, 30 Oct 2020 22:12:16 +0200 Subject: [PATCH] As noticed in my previous commit, the existing jwt libraries added a lot of performance cost between jwt-featured requests and simple requests. That's why a new custom JWT parser was created. This commit adds our custom jwt parser as the underline token signer and verifier --- HISTORY.md | 2 +- NOTICE | 6 +- _examples/auth/jwt/basic/main.go | 74 +- _examples/auth/jwt/middleware/main.go | 91 ++ _examples/auth/jwt/overview/README.md | 29 - _examples/auth/jwt/overview/main.go | 368 -------- .../jwt/overview/rsa_password_protected.key | 30 - _examples/auth/jwt/refresh-token/main.go | 129 ++- .../jwt/refresh-token/rsa_private_key.pem | 27 + .../auth/jwt/refresh-token/rsa_public_key.pem | 9 + go.mod | 20 +- middleware/jwt/alises.go | 166 ++-- middleware/jwt/blocklist.go | 138 +-- middleware/jwt/extractor.go | 71 ++ middleware/jwt/jwt.go | 856 +----------------- middleware/jwt/jwt_test.go | 345 +------ middleware/jwt/rsa_util.go | 106 --- middleware/jwt/signer.go | 59 ++ middleware/jwt/user.go | 187 ---- middleware/jwt/validation.go | 258 ------ middleware/jwt/verifier.go | 210 +++++ 21 files changed, 750 insertions(+), 2431 deletions(-) create mode 100644 _examples/auth/jwt/middleware/main.go delete mode 100644 _examples/auth/jwt/overview/README.md delete mode 100644 _examples/auth/jwt/overview/main.go delete mode 100644 _examples/auth/jwt/overview/rsa_password_protected.key create mode 100644 _examples/auth/jwt/refresh-token/rsa_private_key.pem create mode 100644 _examples/auth/jwt/refresh-token/rsa_public_key.pem create mode 100644 middleware/jwt/extractor.go delete mode 100644 middleware/jwt/rsa_util.go create mode 100644 middleware/jwt/signer.go delete mode 100644 middleware/jwt/user.go delete mode 100644 middleware/jwt/validation.go create mode 100644 middleware/jwt/verifier.go diff --git a/HISTORY.md b/HISTORY.md index 30ca6fe1..fcccde5f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -262,7 +262,7 @@ var dirOpts = iris.DirOptions{ - New builtin [requestid](https://github.com/kataras/iris/tree/master/middleware/requestid) middleware. -- New builtin [JWT](https://github.com/kataras/iris/tree/master/middleware/jwt) middleware based on [square/go-jose](https://github.com/square/go-jose) featured with optional encryption to set claims with sensitive data when necessary. +- New builtin [JWT](https://github.com/kataras/iris/tree/master/middleware/jwt) middleware based on the fastest JWT implementation; [kataras/jwt](https://github.com/kataras/jwt) featured with optional wire encryption to set claims with sensitive data when necessary. - New `iris.RouteOverlap` route registration rule. `Party.SetRegisterRule(iris.RouteOverlap)` to allow overlapping across multiple routes for the same request subdomain, method, path. See [1536#issuecomment-643719922](https://github.com/kataras/iris/issues/1536#issuecomment-643719922). This allows two or more **MVC Controllers** to listen on the same path based on one or more registered dependencies (see [_examples/mvc/authenticated-controller](https://github.com/kataras/iris/tree/master/_examples/mvc/authenticated-controller)). diff --git a/NOTICE b/NOTICE index 5f0c9def..042230a0 100644 --- a/NOTICE +++ b/NOTICE @@ -101,9 +101,9 @@ Revision ID: 5fc50a00491616d5cd0cbce3abd8b699838e25ca toml 3012a1dbe2e4bd1 https://github.com/BurntSushi/toml 391d42b32f0577c b7bbc7f005 - jose d84c719419c2a90 https://github.com/square/go-jose - 8d188ea67e09652 - f5c1929ae8 + jwt 5f34e0a4e28178b https://github.com/kataras/jwt + 3781df69552bdc5 + 481a0d4bef uuid cb32006e483f2a2 https://github.com/google/uuid 3230e24209cf185 c65b477dbf diff --git a/_examples/auth/jwt/basic/main.go b/_examples/auth/jwt/basic/main.go index f4b36699..f37ce681 100644 --- a/_examples/auth/jwt/basic/main.go +++ b/_examples/auth/jwt/basic/main.go @@ -4,41 +4,75 @@ import ( "time" "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/middleware/jwt" + "github.com/kataras/jwt" ) +/* +Learn how to use any JWT 3rd-party package with Iris. +In this example we use the kataras/jwt one. + +Install with: + go get -u github.com/kataras/jwt + +Documentation: + https://github.com/kataras/jwt#table-of-contents +*/ + +// Replace with your own key and keep them secret. +// The "signatureSharedKey" is used for the HMAC(HS256) signature algorithm. +var signatureSharedKey = []byte("sercrethatmaycontainch@r32length") + func main() { app := iris.New() - // With AES-GCM (128) encryption: - // j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - // Without extra encryption, just the sign key: - j := jwt.HMAC(15*time.Minute, "secret") - app.Get("/", generateToken(j)) - app.Get("/protected", j.VerifyMap(), protected) + app.Get("/", generateToken) + app.Get("/protected", protected) app.Listen(":8080") } -func generateToken(j *jwt.JWT) iris.Handler { - return func(ctx iris.Context) { - token, err := j.Token(iris.Map{ - "foo": "bar", - }) - if err != nil { - ctx.StopWithStatus(iris.StatusInternalServerError) - return - } +type fooClaims struct { + Foo string `json:"foo"` +} - ctx.HTML(`Token: ` + token + `

- /secured?token=` + token + ``) +func generateToken(ctx iris.Context) { + claims := fooClaims{ + Foo: "bar", } + + // Sign and generate compact form token. + token, err := jwt.Sign(jwt.HS256, signatureSharedKey, claims, jwt.MaxAge(10*time.Minute)) + if err != nil { + ctx.StopWithStatus(iris.StatusInternalServerError) + return + } + + tokenString := string(token) // or jwt.BytesToString + ctx.HTML(`Token: ` + tokenString + `

+ /protected?token=` + tokenString + ``) } func protected(ctx iris.Context) { + // Extract the token, e.g. cookie, Authorization: Bearer $token + // or URL query. + token := ctx.URLParam("token") + // Verify the token. + verifiedToken, err := jwt.Verify(jwt.HS256, signatureSharedKey, []byte(token)) + if err != nil { + ctx.StopWithStatus(iris.StatusUnauthorized) + return + } + ctx.Writef("This is an authenticated request.\n\n") - claims := jwt.Get(ctx).(iris.Map) + // Decode the custom claims. + var claims fooClaims + verifiedToken.Claims(&claims) - ctx.Writef("foo=%s\n", claims["foo"]) + // Just an example on how you can retrieve all the standard claims (set by jwt.MaxAge, "exp"). + standardClaims := jwt.GetVerifiedToken(ctx).StandardClaims + expiresAtString := standardClaims.ExpiresAt().Format(ctx.Application().ConfigurationReadOnly().GetTimeFormat()) + timeLeft := standardClaims.Timeleft() + + ctx.Writef("foo=%s\nexpires at: %s\ntime left: %s\n", claims.Foo, expiresAtString, timeLeft) } diff --git a/_examples/auth/jwt/middleware/main.go b/_examples/auth/jwt/middleware/main.go new file mode 100644 index 00000000..948a0c4c --- /dev/null +++ b/_examples/auth/jwt/middleware/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/jwt" +) + +var ( + sigKey = []byte("signature_hmac_secret_shared_key") + encKey = []byte("GCM_AES_256_secret_shared_key_32") +) + +type fooClaims struct { + Foo string `json:"foo"` +} + +/* +In this example you will learn the essentials +of the Iris builtin JWT middleware based on the github.com/kataras/jwt package. +*/ + +func main() { + app := iris.New() + + signer := jwt.NewSigner(jwt.HS256, sigKey, 10*time.Minute) + // Enable payload encryption with: + // signer.WithGCM(encKey, nil) + app.Get("/", generateToken(signer)) + + verifier := jwt.NewVerifier(jwt.HS256, sigKey) + // Enable server-side token block feature (even before its expiration time): + verifier.WithDefaultBlocklist() + // Enable payload decryption with: + // verifier.WithGCM(encKey, nil) + verifyMiddleware := verifier.Verify(func() interface{} { + return new(fooClaims) + }) + + protectedAPI := app.Party("/protected") + // Register the verify middleware to allow access only to authorized clients. + protectedAPI.Use(verifyMiddleware) + // ^ or UseRouter(verifyMiddleware) to disallow unauthorized http error handlers too. + + protectedAPI.Get("/", protected) + // Invalidate the token through server-side, even if it's not expired yet. + protectedAPI.Get("/logout", logout) + + // http://localhost:8080 + // http://localhost:8080/protected?token=$token (or Authorization: Bearer $token) + // http://localhost:8080/protected/logout?token=$token + // http://localhost:8080/protected?token=$token (401) + app.Listen(":8080") +} + +func generateToken(signer *jwt.Signer) iris.Handler { + return func(ctx iris.Context) { + claims := fooClaims{Foo: "bar"} + + token, err := signer.Sign(claims) + if err != nil { + ctx.StopWithStatus(iris.StatusInternalServerError) + return + } + + ctx.Write(token) + } +} + +func protected(ctx iris.Context) { + // Get the verified and decoded claims. + claims := jwt.Get(ctx).(*fooClaims) + + // Optionally, get token information if you want to work with them. + // Just an example on how you can retrieve all the standard claims (set by signer's max age, "exp"). + standardClaims := jwt.GetVerifiedToken(ctx).StandardClaims + expiresAtString := standardClaims.ExpiresAt().Format(ctx.Application().ConfigurationReadOnly().GetTimeFormat()) + timeLeft := standardClaims.Timeleft() + + ctx.Writef("foo=%s\nexpires at: %s\ntime left: %s\n", claims.Foo, expiresAtString, timeLeft) +} + +func logout(ctx iris.Context) { + err := ctx.Logout() + if err != nil { + ctx.WriteString(err.Error()) + } else { + ctx.Writef("token invalidated, a new token is required to access the protected API") + } +} diff --git a/_examples/auth/jwt/overview/README.md b/_examples/auth/jwt/overview/README.md deleted file mode 100644 index 1d2e0ae5..00000000 --- a/_examples/auth/jwt/overview/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Generate RSA - -```sh -$ openssl genrsa -des3 -out private_rsa.pem 2048 -``` - -```go -b, err := ioutil.ReadFile("./private_rsa.pem") -if err != nil { - panic(err) -} -key := jwt.MustParseRSAPrivateKey(b, []byte("pass")) -``` - -OR - -```go -import "crypto/rand" -import "crypto/rsa" - -key, err := rsa.GenerateKey(rand.Reader, 2048) -``` - -# Generate Ed25519 - -```sh -$ openssl genpkey -algorithm Ed25519 -out private_ed25519.pem -$ openssl req -x509 -key private_ed25519.pem -out cert_ed25519.pem -days 365 -``` diff --git a/_examples/auth/jwt/overview/main.go b/_examples/auth/jwt/overview/main.go deleted file mode 100644 index ee2ef42f..00000000 --- a/_examples/auth/jwt/overview/main.go +++ /dev/null @@ -1,368 +0,0 @@ -// Package main shows how you can use the Iris unique JWT middleware. -// The file contains different kind of examples that all do the same job but, -// depending on your code style and your application's requirements, you may choose one over other. -package main - -import ( - "time" - - "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/middleware/jwt" -) - -// Claims a custom claims structure. -type Claims struct { - // Optionally define JWT's "iss" (Issuer), - // "sub" (Subject) and "aud" (Audience) for issuer and subject. - // The JWT's "exp" (expiration) and "iat" (issued at) are automatically - // set by the middleware. - Issuer string `json:"iss"` - Subject string `json:"sub"` - Audience []string `json:"aud"` - /* - Note that the above fields can be also extracted via: - jwt.GetTokenInfo(ctx).Claims - But in that example, we just showcase how these info can be embedded - inside your own Go structure. - */ - - // Optionally define a "exp" (Expiry), - // unlike the rest, this is unset on creation - // (unless you want to override the middleware's max age option), - // it's filled automatically by the JWT middleware - // when the request token is verified. - // See the POST /user route. - Expiry *jwt.NumericDate `json:"exp"` - - Username string `json:"username"` -} - -func main() { - // Get keys from system's environment variables - // JWT_SECRET (for signing and verification) and JWT_SECRET_ENC(for encryption and decryption), - // or defaults to "secret" and "itsa16bytesecret" respectfully. - // - // Use the `jwt.New` instead for more flexibility, if necessary. - j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - - /* - By default it extracts the token from url parameter "token={token}" - and the Authorization Bearer {token} header. - You can also take token from JSON body: - j.Extractors = append(j.Extractors, jwt.FromJSON) - */ - - /* Optionally, enable block list to force-invalidate - verified tokens even before their expiration time. - This is useful when the client doesn't clear - the token on a user logout by itself. - - The duration argument clears any expired token on each every tick. - There is a GC() method that can be manually called to clear expired blocked tokens - from the memory. - - j.Blocklist = jwt.NewBlocklist(30*time.Minute) - OR NewBlocklistContext(stdContext, 30*time.Minute) - - - To invalidate a verified token just call: - j.Invalidate(ctx) inside a route handler. - */ - - app := iris.New() - app.Logger().SetLevel("debug") - - app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { - // Note that, any error stored by an authentication - // method in Iris is an iris.ErrPrivate. - // Available jwt errors: - // - ErrMissing - // - ErrMissingKey - // - ErrExpired - // - ErrNotValidYet - // - ErrIssuedInTheFuture - // - ErrBlocked - // An iris.ErrPrivate SHOULD never be displayed to the client as it is; - // because it may contain critical security information about the server. - // - // Also keep in mind that JWT middleware logs verification errors to the - // application's logger ("debug") so, normally you don't have to - // bother showing the verification error to the browser/client. - // However, you can retrieve that error and do what ever you feel right: - if err := ctx.GetErr(); err != nil { - // If we have an error stored, - // (JWT middleware stores any verification errors to the Context), - // set the error as response body, - // which is the default behavior if that - // wasn't an authentication error (as explained above) - ctx.WriteString(err.Error()) - } else { - // Else, the default behavior when no error was occured; - // write the status text of the status code: - ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) - } - }) - - app.Get("/authenticate", func(ctx iris.Context) { - claims := &Claims{ - Issuer: "server", - Audience: []string{"user"}, - Username: "kataras", - } - - // WriteToken generates and sends the token to the client. - // To generate a token use: tok, err := j.Token(claims) - // then you can write it in any form you'd like. - // The expiration JWT fields are automatically - // set by the middleware, that means that your claims value - // only needs to fill fields that your application specifically requires. - j.WriteToken(ctx, claims) - }) - - // Middleware + type-safe method, - // useful in 99% of the cases, when your application - // requires token verification under a whole path prefix, e.g. /protected: - protectedAPI := app.Party("/protected") - { - protectedAPI.Use(j.Verify(func() interface{} { - // Must return a pointer to a type. - // - // The Iris JWT implementation is very sophisticated. - // We keep our claims in type-safe form. - // However, you are free to use raw Go maps - // (map[string]interface{} or iris.Map) too (example later on). - // - // Note that you can use the same "j" JWT instance - // to serve different types of claims on other group of routes, - // e.g. postRouter.Use(j.Verify(... return new(Post))). - return new(Claims) - })) - - protectedAPI.Get("/", func(ctx iris.Context) { - claims := jwt.Get(ctx).(*Claims) - // All fields parsed from token are set to the claims, - // including the Expiry (if defined). - ctx.Writef("Username: %s\nExpires at: %s\nAudience: %s", - claims.Username, claims.Expiry.Time(), claims.Audience) - }) - } - - // Verify token inside a handler method, - // useful when you just need to verify a token on a single spot: - app.Get("/inline", func(ctx iris.Context) { - var claims Claims - _, err := j.VerifyToken(ctx, &claims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", - claims.Username, claims.Expiry.Time()) - }) - - // Use a common map as claims method, - // not recommended, as we support typed claims but - // you can do it: - app.Get("/map/authenticate", func(ctx iris.Context) { - claims := map[string]interface{}{ // or iris.Map for shortcut. - "username": "kataras", - } - - j.WriteToken(ctx, claims) - }) - - app.Get("/map/verify/middleware", j.Verify(func() interface{} { - return &iris.Map{} // or &map[string]interface{}{} - }), func(ctx iris.Context) { - claims := jwt.Get(ctx).(iris.Map) - // The Get method will unwrap the *iris.Map for you, - // so its values are directly accessible: - ctx.Writef("Username: %s\nExpires at: %s\n", - claims["username"], claims["exp"].(*jwt.NumericDate).Time()) - }) - - app.Get("/map/verify", func(ctx iris.Context) { - claims := make(iris.Map) // or make(map[string]interface{}) - - tokenInfo, err := j.VerifyToken(ctx, &claims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", - claims["username"], tokenInfo.Claims.Expiry.Time()) /* the claims["exp"] is also set. */ - }) - - // Use the new Context.User() to retrieve the verified client method: - // 1. Create a go stuct that implements the context.User interface: - app.Get("/users/authenticate", func(ctx iris.Context) { - user := &User{Username: "kataras"} - j.WriteToken(ctx, user) - }) - usersAPI := app.Party("/users") - { - usersAPI.Use(j.Verify(func() interface{} { - return new(User) - })) - - usersAPI.Get("/", func(ctx iris.Context) { - user := ctx.User() - userToken, _ := user.GetToken() - /* - You can also cast it to the underline implementation - and work with its fields: - expires := user.(*User).Expiry.Time() - */ - // OR use the GetTokenInfo to get the parsed token information: - expires := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() - lifetime := expires.Sub(time.Now()) // remeaning time to be expired. - - ctx.Writef("Username: %s\nAuthenticated at: %s\nLifetime: %s\nToken: %s\n", - user.GetUsername(), user.GetAuthorizedAt(), lifetime, userToken) - }) - } - - // http://localhost:8080/authenticate - // http://localhost:8080/protected?token={token} - // http://localhost:8080/inline?token={token} - // - // http://localhost:8080/map/authenticate - // http://localhost:8080/map/verify?token={token} - // http://localhost:8080/map/verify/middleware?token={token} - // - // http://localhost:8080/users/authenticate - // http://localhost:8080/users?token={token} - app.Listen(":8080") -} - -// User is a custom implementation of the Iris Context User interface. -// Optionally, for JWT, you can also implement -// the SetToken(tok string) and -// Validate(ctx iris.Context, claims jwt.Claims, e jwt.Expected) error -// methods to set a token and add custom validation -// to a User value parsed from a token. -type User struct { - iris.User - Username string `json:"username"` - - // Optionally, declare some JWT fields, - // they are automatically filled by the middleware itself. - IssuedAt *jwt.NumericDate `json:"iat"` - Expiry *jwt.NumericDate `json:"exp"` - Token string `json:"-"` -} - -// GetUsername returns the Username. -// Look the iris/context.SimpleUser type -// for all the methods you can implement. -func (u *User) GetUsername() string { - return u.Username -} - -// GetAuthorizedAt returns the IssuedAt time. -// This and the Get/SetToken methods showcase how you can map JWT standard fields -// to an Iris Context User. -func (u *User) GetAuthorizedAt() time.Time { - return u.IssuedAt.Time() -} - -// GetToken is a User interface method. -func (u *User) GetToken() (string, error) { - return u.Token, nil -} - -// SetToken is a special jwt.TokenSetter interface which is -// called automatically when a token is parsed to this User value. -func (u *User) SetToken(tok string) { - u.Token = tok -} - -/* -func default_RSA_Example() { - j := jwt.RSA(15*time.Minute) -} - -Same as: - -func load_File_Or_Generate_RSA_Example() { - signKey, err := jwt.LoadRSA("jwt_sign.key", 2048) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS256, signKey) - if err != nil { - panic(err) - } - - encKey, err := jwt.LoadRSA("jwt_enc.key", 2048) - if err != nil { - panic(err) - } - err = j.WithEncryption(jwt.A128CBCHS256, jwt.RSA15, encKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func hmac_Example() { - // hmac - key := []byte("secret") - j, err := jwt.New(15*time.Minute, jwt.HS256, key) - if err != nil { - panic(err) - } - - // OPTIONAL encryption: - encryptionKey := []byte("itsa16bytesecret") - err = j.WithEncryption(jwt.A128GCM, jwt.DIRECT, encryptionKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func load_From_File_With_Password_Example() { - b, err := ioutil.ReadFile("./rsa_password_protected.key") - if err != nil { - panic(err) - } - signKey,err := jwt.ParseRSAPrivateKey(b, []byte("pass")) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS256, signKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func generate_RSA_Example() { - signKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - panic(err) - } - - encryptionKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS512, signKey) - if err != nil { - panic(err) - } - err = j.WithEncryption(jwt.A128CBCHS256, jwt.RSA15, encryptionKey) - if err != nil { - panic(err) - } -} -*/ diff --git a/_examples/auth/jwt/overview/rsa_password_protected.key b/_examples/auth/jwt/overview/rsa_password_protected.key deleted file mode 100644 index e93fff77..00000000 --- a/_examples/auth/jwt/overview/rsa_password_protected.key +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -Proc-Type: 4,ENCRYPTED -DEK-Info: DES-EDE3-CBC,6B0BC214C94124FE - -lAM48DEM/GdCDimr9Vhi+fSHLgduDb0l2BA4uhILgNby51jxY/4X3IqM6f3ImKX7 -cEd9OBug+pwIugB0UW0L0f5Pd59Ovpiaz3xLci1/19ehYnMqsuP3YAnJm40hT5VP -p0gWRiR415PJ0fPeeJPFx5IsqvkTJ30LWZHUZX4EkdcL5L8PrVbmthGDbLh+OcMc -LzoP8eTglzlZF03nyvAol6+p2eZtvOJLu8nWG25q17kyBx6kEiCsWFcUBTX9G7sH -CM3naByDijqZXE/XXtmTMLSRRnlk7Q5WLxClroHlUP9y8BQFMo2TW4Z+vNjHUkc1 -77ghabX1704bAlIE8LLZJKrm/C5+VKyV6117SVG/2bc4036Y5rStXpANbk1j4K0x -ADvpRhuTpifaogdvJP+8eXBdl841MQMRzWuZHp6UNYYQegoV9C+KHyJx4UPjZyzd -gblZmKgU+BsX3mV6MLhJtd6dheLZtpBsAlSstJxzmgwqz9faONYEGeItXO+NnxbA -mxAp/mI+Fz2jfgYlWjwkyPPzD4k/ZMMzB4XLkKKs9XaxUtTomiDkuYZfnABhxt73 -xBy40V1rb/NyeW80pk1zEHM6Iy/48ETSp9U3k9sSOXjMhYbPXgxDtimV8w0qGFAo -2Tif7ZuaiuC38rOkoHK9C6vy2Dp8lQZ+QBnUKLeFsyhq9CaqSdnyUTMj3oEZXXf+ -TqqeO+PTtl7JaNfGRq6/aMZqxACHkyVUvYvjZzx07CJ2fr+OtNqxallM6Oc/o9NZ -5u7lpgrYaKM/b67q0d2X/AoxR5zrZuM8eam3acD1PwHFQKbJWuFNmjWtnlZNuR3X -fZEmxIKwDlup8TxFcqbbZtPHuQA2mTMTqfRkf8oPSO+N6NNaUpb0ignYyA7Eu5GT -b02d/oNLETMikxUxntMSH7GhuOpfJyELz8krYTttbJ+a93h4wBeYW2+LyAr/cRLB -mbtKLtaN7f3FaOSnu8e0+zlJ7xglHPXqblRL9q6ZDM5UJtJD4rA7LPZHk/0Y1Kb6 -hBh1qMDu0r3IV4X7MDacvxw7aa7D8TyXJiFSvxykVhds+ndjIe51Ics5908+lev3 -nwE69PLMwyqe2vvE2oDwao4XJuBLCHjcv/VagRSz/XQGMbZqb3L6unyd3UPl8JjP -ovipNwM4rFnE54uiUUeki7TZGDYO72vQcSaLrmbeAWc2m202+rqLz0WMm6HpPmCv -IgexpX2MnIeHJ3+BlEjA2u+S6xNSD7qHGk2pb7DD8nRvUdSHAHeaQbrkEfEhhR2Q -Dw5gdw1JyQ0UKBl5ndn/1Ub2Asl016lZjpqHyMIVS4tFixACDsihEYMmq/zQmTj4 -8oBZTU+fycN/KiGKZBsqxIwgYIeMz/GfvoyN5m57l6fwEZALVpveI1pP4fiZB/Z8 -xLKa5JK6L10lAD1YHWc1dPhamf9Sb3JwN2CFtGvjOJ/YjAZu3jJoxi40DtRkE3Rh -HI8Cbx1OORzoo0kO0vy42rz5qunYyVmEzPKtOj+YjVEhVJ85yJZ9bTZtuyqMv8mH -cnwEeIFK8cmm9asbVzQGDwN/UGB4cO3LrMX1RYk4GRttTGlp0729BbmZmu00RnD/ ------END RSA PRIVATE KEY----- diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index f48c8231..cccf3266 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -1,57 +1,65 @@ package main import ( + "fmt" "time" "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/middleware/jwt" ) +const ( + accessTokenMaxAge = 10 * time.Minute + refreshTokenMaxAge = time.Hour +) + +var ( + privateKey, publicKey = jwt.MustLoadRSA("rsa_private_key.pem", "rsa_public_key.pem") + + signer = jwt.NewSigner(jwt.RS256, privateKey, accessTokenMaxAge) + verifier = jwt.NewVerifier(jwt.RS256, publicKey) +) + // UserClaims a custom access claims structure. type UserClaims struct { - // 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"` + ID string `json:"user_id"` + // Do: `json:"username,required"` to have this field required + // or see the Validate method below instead. Username string `json:"username"` } +// Validate completes the middleware's custom ClaimsValidator. +// It will not accept a token which its claims missing the username field +// (useful to not accept refresh tokens generated by the same algorithm). +func (u *UserClaims) Validate() error { + if u.Username == "" { + return fmt.Errorf("username field is missing") + } + + return nil +} + // For refresh token, we will just use the jwt.Claims // structure which contains the standard JWT fields. func main() { app := iris.New() + app.OnErrorCode(iris.StatusUnauthorized, handleUnauthorized) - j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - - app.Get("/authenticate", func(ctx iris.Context) { - generateTokenPair(ctx, j) - }) - - app.Get("/refresh", func(ctx iris.Context) { - refreshToken(ctx, j) - }) + app.Get("/authenticate", generateTokenPair) + app.Get("/refresh", refreshToken) protectedAPI := app.Party("/protected") { - protectedAPI.Use(j.Verify(func() interface{} { + verifyMiddleware := verifier.Verify(func() interface{} { return new(UserClaims) - })) // OR j.VerifyToken(ctx, &claims, jwt.MeetRequirements(&UserClaims{})) + }) + + protectedAPI.Use(verifyMiddleware) protectedAPI.Get("/", func(ctx iris.Context) { - // Get token info, even if our UserClaims does not embed those - // through GetTokenInfo: - expiresAt := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() - // Get your custom JWT claims through Get, - // which is a shortcut of GetTokenInfo(ctx).Value: claims := jwt.Get(ctx).(*UserClaims) - - ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, expiresAt) + ctx.Writef("Username: %s\n", claims.Username) }) } @@ -59,33 +67,33 @@ 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?token={refresh_token} + // http://localhost:8080/refresh?refresh_token={refresh_token} // OR http://localhost:8080/refresh (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) - // OR http://localhost:8080/refresh (request PLAIN TEXT of {refresh_token}) (200) (response JSON {access_token, refresh_token}) - // http://localhost:8080/refresh?token={access_token} (401) + // http://localhost:8080/refresh?refresh_token={access_token} (401) app.Listen(":8080") } -func generateTokenPair(ctx iris.Context, j *jwt.JWT) { +func generateTokenPair(ctx iris.Context) { // Simulate a user... userID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" // Map the current user with the refresh token, // so we make sure, on refresh route, that this refresh token owns // to that user before re-generate. - refresh := jwt.Claims{Subject: userID} + refreshClaims := jwt.Claims{Subject: userID} - access := UserClaims{ + accessClaims := UserClaims{ ID: userID, Username: "kataras", } // Generates a Token Pair, long-live for refresh tokens, e.g. 1 hour. - // Second argument is the refresh claims and, - // the last one is the access token's claims. - tokenPair, err := j.TokenPair(1*time.Hour, refresh, access) + // First argument is the access claims, + // second argument is the refresh claims, + // third argument is the refresh max age. + tokenPair, err := signer.NewTokenPair(accessClaims, refreshClaims, refreshTokenMaxAge) if err != nil { - ctx.Application().Logger().Debugf("token pair: %v", err) + ctx.Application().Logger().Errorf("token pair: %v", err) ctx.StopWithStatus(iris.StatusInternalServerError) return } @@ -95,14 +103,12 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) { ctx.JSON(tokenPair) } -func refreshToken(ctx iris.Context, j *jwt.JWT) { - /* - We could pass a jwt.Claims pointer as the second argument, - but we don't have to because the method already returns - the standard JWT claims information back to us: - refresh, err := VerifyRefreshToken(ctx, nil) - */ - +// There are various methods of refresh token, depending on the application requirements. +// In this example we will accept a refresh token only, we will verify only a refresh token +// and we re-generate a whole new pair. An alternative would be to accept a token pair +// of both access and refresh tokens, verify the refresh, verify the access with a Leeway time +// and check if its going to expire soon, then generate a single access token. +func refreshToken(ctx iris.Context) { // Assuming you have access to the current user, e.g. sessions. // // Simulate a database call against our jwt subject @@ -110,23 +116,46 @@ func refreshToken(ctx iris.Context, j *jwt.JWT) { // * Note: You can remove the ExpectSubject and do this validation later on by yourself. currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" + // Get the refresh token from ?refresh_token=$token OR + // the request body's JSON{"refresh_token": "$token"}. + refreshToken := []byte(ctx.URLParam("refresh_token")) + if len(refreshToken) == 0 { + // You can read the whole body with ctx.GetBody/ReadBody too. + var tokenPair jwt.TokenPair + if err := ctx.ReadJSON(&tokenPair); err != nil { + ctx.StopWithError(iris.StatusBadRequest, err) + return + } + + refreshToken = tokenPair.RefreshToken + } + // Verify the refresh token, which its subject MUST match the "currentUserID". - _, err := j.VerifyRefreshToken(ctx, nil, jwt.ExpectSubject(currentUserID)) + _, err := verifier.VerifyToken(refreshToken, jwt.Expected{Subject: currentUserID}) if err != nil { - ctx.Application().Logger().Debugf("verify refresh token: %v", err) + ctx.Application().Logger().Errorf("verify refresh token: %v", err) ctx.StatusCode(iris.StatusUnauthorized) return } /* Custom validation checks can be performed after Verify calls too: currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" - userID := refresh.Claims.Subject + userID := verifiedToken.StandardClaims.Subject if userID != currentUserID { ctx.StopWithStatus(iris.StatusUnauthorized) return } */ - // All OK, re-generate the new pair and send to client. - generateTokenPair(ctx, j) + // All OK, re-generate the new pair and send to client, + // we could only generate an access token as well. + generateTokenPair(ctx) +} + +func handleUnauthorized(ctx iris.Context) { + if err := ctx.GetErr(); err != nil { + ctx.Application().Logger().Errorf("unauthorized: %v", err) + } + + ctx.WriteString("Unauthorized") } diff --git a/_examples/auth/jwt/refresh-token/rsa_private_key.pem b/_examples/auth/jwt/refresh-token/rsa_private_key.pem new file mode 100644 index 00000000..411e3a89 --- /dev/null +++ b/_examples/auth/jwt/refresh-token/rsa_private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN PRIVATE KEY----- +MIIEowIBAAKCAQEArwO0q8WbBvrplz3lTQjsWu66HC7M3mVAjmjLq8Wj/ipqVtiJ +MrUL9t/0q9PNO/KX9u+HayFNYM4TnYkXVZX3M5E31W8fPPy74D/XpqFwrwT7bAEw +pT51JJyxkoBAyOh08lmR2EYvpGF7qErra7qbkk4LGFbhoFCXdMLXguT4rPymkzFH +dQrmGYOBS+v9imSuJddCZpXyv6Ko7AKB4mhzg4RC5RJZO5GEHVUrSMHxZB0syF8c +U+28iL8A7SlGKTNZPZiHmCQVRqA6WlllL/YV/t6p24kaNZBUp9JGbAzOeKuVUv2u +vfNKwB/aBwnFKauM9I6RmC4bnI1nGHjETlNNWwIDAQABAoIBAHBPKHmybTGlgpET +nzo4J7SSzcuYHM/6mdrJVSn9wqcwAN2KR0DK/cqHHTPGz0VRAEPuojAVRtqAZAYM +G3VIr0HgRrwoextf9BCL549+uhkWUWGVwenIktPT2f/xXaGPyrxazkTDhX8vL3Nn +4HtZXMweWPBdkJyYGxlKj5Hn7czTpG3VKpvpHeFlY4caF+FT2as1jcQ1MjPnGslH +Ss+sYPBp/70w2T114Z4wlR4OryI1LeuFeje9obrn0HAmJd0ZKYM21awp/YWJ/y8J +wIH6XQ4AGR9iTRhuffK1XRM/Iec3K/YhOn4PtKdT7OsIujAKY7A9WcqSFif+/E1g +jom3eMECgYEAw5Zdqt2uZ19FuDlDTW4Kw8Z2NyXgWp33LkAXG1mJw7bqDhfPeB1c +xTPs4i4RubGuDusygxZ3GgJAO7tLGzNQfWNoi03mM7Q/BJGkA9VZr+U28zsSRQOQ ++J9xNsdgUMP1js7X/NNM2bxTC8zy9wEsWr9JwNo1C7uHTE9WXAumBI8CgYEA5RKV +niSbyko36W3Vi0ZnGBrRhy0Eiq85V2mhWzHN+txcv+8aISow2wioTUzrpR0aVZ4j +v9+siJENlALVzdUFihy0lPxHqLJT746Cixz95WRTLkdHeNllV0DMfOph2x3j1Hjd +3PgTv+jqb6npY0/2Vb2pp4t/zVikGaObsAalSHUCgYBne8B1bjMfqI3n6gxNBIMX +kILtrNGmwFuPEgPnyZkVf0sZR8nSwJ5cDJwyE7P3LyZr6E9igllj3nsD35Xef2j/ +3r/qrL2275BEJ5bDHHgGk91eFgwVjcx/b0TkedrhAL2E4LXwpA/OSFEcNkT7IZjJ +Ltqj+hAE9CSi4HtN2i/tywKBgBotKn28zzSpkIQTMgDNVcCSZ/kbctZqOZI8lty1 +70TIY6znJMQ/bv/ImHrk3FSs47J+9LTbWXrtoHCWdlokCpMCvrv7rDCh2Cea0F4X +PQg2k67JJGix5vu2guePXQlN/Bfui+PRUWhvtEJ4VxwrKgoYN0fXEA6mH3JymLrf +t4l1AoGBALk4o9swGjw7MnByYJmOidlJ0p9Wj1BWWJJYoYX2VfjIuvZj6BNxkEb0 +aVmYRC+40e9L1rOyrlyaO/TiQaIPE4ljVs/AmMKGz8sIcVfwdyERH3nDrXxvlAav +lSvfKoYM3J+5c63CDuU45gztpmavNerzCczqYTLOEMx1eCLHOQlx +-----END PRIVATE KEY----- diff --git a/_examples/auth/jwt/refresh-token/rsa_public_key.pem b/_examples/auth/jwt/refresh-token/rsa_public_key.pem new file mode 100644 index 00000000..99a96176 --- /dev/null +++ b/_examples/auth/jwt/refresh-token/rsa_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArwO0q8WbBvrplz3lTQjs +Wu66HC7M3mVAjmjLq8Wj/ipqVtiJMrUL9t/0q9PNO/KX9u+HayFNYM4TnYkXVZX3 +M5E31W8fPPy74D/XpqFwrwT7bAEwpT51JJyxkoBAyOh08lmR2EYvpGF7qErra7qb +kk4LGFbhoFCXdMLXguT4rPymkzFHdQrmGYOBS+v9imSuJddCZpXyv6Ko7AKB4mhz +g4RC5RJZO5GEHVUrSMHxZB0syF8cU+28iL8A7SlGKTNZPZiHmCQVRqA6WlllL/YV +/t6p24kaNZBUp9JGbAzOeKuVUv2uvfNKwB/aBwnFKauM9I6RmC4bnI1nGHjETlNN +WwIDAQAB +-----END PUBLIC KEY----- diff --git a/go.mod b/go.mod index 6f9f219c..86a975a6 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.15 require ( github.com/BurntSushi/toml v0.3.1 - github.com/CloudyKit/jet/v5 v5.1.0 + github.com/CloudyKit/jet/v5 v5.1.1 github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 github.com/andybalholm/brotli v1.0.1 github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible @@ -12,7 +12,7 @@ require ( github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/fatih/structs v1.1.0 github.com/flosch/pongo2/v4 v4.0.0 - github.com/go-redis/redis/v8 v8.3.1 + github.com/go-redis/redis/v8 v8.3.3 github.com/google/uuid v1.1.2 github.com/hashicorp/go-version v1.2.1 github.com/iris-contrib/httpexpect/v2 v2.0.5 @@ -21,24 +21,24 @@ require ( github.com/json-iterator/go v1.1.10 github.com/kataras/blocks v0.0.4 github.com/kataras/golog v0.1.5 + github.com/kataras/jwt v0.0.4 github.com/kataras/neffos v0.0.16 github.com/kataras/pio v0.0.10 github.com/kataras/sitemap v0.0.5 github.com/kataras/tunnel v0.0.2 - github.com/klauspost/compress v1.11.1 + github.com/klauspost/compress v1.11.2 github.com/mailru/easyjson v0.7.6 github.com/microcosm-cc/bluemonday v1.0.4 github.com/russross/blackfriday/v2 v2.0.1 github.com/schollz/closestmatch v2.1.0+incompatible - github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 - github.com/tdewolff/minify/v2 v2.9.9 - github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 + github.com/tdewolff/minify/v2 v2.9.10 + github.com/vmihailenco/msgpack/v5 v5.0.0-beta.9 github.com/yosssi/ace v0.0.5 go.etcd.io/bbolt v1.3.5 - golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee - golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 - golang.org/x/sys v0.0.0-20201016160150-f659759dc4ca - golang.org/x/text v0.3.3 + golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 + golang.org/x/net v0.0.0-20201027133719-8eef5233e2a1 + golang.org/x/sys v0.0.0-20201028094953-708e7fb298ac + golang.org/x/text v0.3.4 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e google.golang.org/protobuf v1.25.0 gopkg.in/ini.v1 v1.62.0 diff --git a/middleware/jwt/alises.go b/middleware/jwt/alises.go index da34d9c7..df27cb5d 100644 --- a/middleware/jwt/alises.go +++ b/middleware/jwt/alises.go @@ -1,91 +1,105 @@ package jwt import ( - "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/json" - "github.com/square/go-jose/v3/jwt" + "github.com/kataras/jwt" ) +// Type alises for the underline jwt package. type ( - // Claims represents public claim values (as specified in RFC 7519). + // Alg is the signature algorithm interface alias. + Alg = jwt.Alg + // Claims represents the standard claim values (as specified in RFC 7519). Claims = jwt.Claims - // Audience represents the recipients that the token is intended for. - Audience = jwt.Audience - // NumericDate represents date and time as the number of seconds since the - // epoch, including leap seconds. Non-integer values can be represented - // in the serialized format, but we round to the nearest second. - NumericDate = jwt.NumericDate - // Expected defines values used for protected claims validation. - // If field has zero value then validation is skipped. + // Expected is a TokenValidator which performs simple checks + // between standard claims values. + // + // Usage: + // expecteed := jwt.Expected{ + // Issuer: "my-app", + // } + // verifiedToken, err := verifier.Verify(..., expected) Expected = jwt.Expected -) -var ( - // NewNumericDate constructs NumericDate from time.Time value. - NewNumericDate = jwt.NewNumericDate - // Marshal returns the JSON encoding of v. - Marshal = json.Marshal - // Unmarshal parses the JSON-encoded data and stores the result - // in the value pointed to by v. - Unmarshal = json.Unmarshal -) - -type ( - // KeyAlgorithm represents a key management algorithm. - KeyAlgorithm = jose.KeyAlgorithm - - // SignatureAlgorithm represents a signature (or MAC) algorithm. - SignatureAlgorithm = jose.SignatureAlgorithm - - // ContentEncryption represents a content encryption algorithm. - ContentEncryption = jose.ContentEncryption -) - -// Key management algorithms. -const ( - ED25519 = jose.ED25519 - RSA15 = jose.RSA1_5 - RSAOAEP = jose.RSA_OAEP - RSAOAEP256 = jose.RSA_OAEP_256 - A128KW = jose.A128KW - A192KW = jose.A192KW - A256KW = jose.A256KW - DIRECT = jose.DIRECT - ECDHES = jose.ECDH_ES - ECDHESA128KW = jose.ECDH_ES_A128KW - ECDHESA192KW = jose.ECDH_ES_A192KW - ECDHESA256KW = jose.ECDH_ES_A256KW - A128GCMKW = jose.A128GCMKW - A192GCMKW = jose.A192GCMKW - A256GCMKW = jose.A256GCMKW - PBES2HS256A128KW = jose.PBES2_HS256_A128KW - PBES2HS384A192KW = jose.PBES2_HS384_A192KW - PBES2HS512A256KW = jose.PBES2_HS512_A256KW + // TokenValidator is the token validator interface alias. + TokenValidator = jwt.TokenValidator + // VerifiedToken is the type alias for the verfieid token type, + // the result of the VerifyToken function. + VerifiedToken = jwt.VerifiedToken + // SignOption used to set signing options at Sign function. + SignOption = jwt.SignOption + // TokenPair is just a helper structure which holds both access and refresh tokens. + TokenPair = jwt.TokenPair ) // Signature algorithms. -const ( - EdDSA = jose.EdDSA - HS256 = jose.HS256 - HS384 = jose.HS384 - HS512 = jose.HS512 - RS256 = jose.RS256 - RS384 = jose.RS384 - RS512 = jose.RS512 - ES256 = jose.ES256 - ES384 = jose.ES384 - ES512 = jose.ES512 - PS256 = jose.PS256 - PS384 = jose.PS384 - PS512 = jose.PS512 +var ( + EdDSA = jwt.EdDSA + HS256 = jwt.HS256 + HS384 = jwt.HS384 + HS512 = jwt.HS512 + RS256 = jwt.RS256 + RS384 = jwt.RS384 + RS512 = jwt.RS512 + ES256 = jwt.ES256 + ES384 = jwt.ES384 + ES512 = jwt.ES512 + PS256 = jwt.PS256 + PS384 = jwt.PS384 + PS512 = jwt.PS512 ) -// Content encryption algorithms. -const ( - A128CBCHS256 = jose.A128CBC_HS256 - A192CBCHS384 = jose.A192CBC_HS384 - A256CBCHS512 = jose.A256CBC_HS512 - A128GCM = jose.A128GCM - A192GCM = jose.A192GCM - A256GCM = jose.A256GCM +// Encryption algorithms. +var ( + GCM = jwt.GCM + // Helper to generate random key, + // can be used to generate hmac signature key and GCM+AES for testing. + MustGenerateRandom = jwt.MustGenerateRandom +) + +var ( + // Leeway adds validation for a leeway expiration time. + // If the token was not expired then a comparison between + // this "leeway" and the token's "exp" one is expected to pass instead (now+leeway > exp). + // Example of use case: disallow tokens that are going to be expired in 3 seconds from now, + // this is useful to make sure that the token is valid when the when the user fires a database call for example. + // Usage: + // verifiedToken, err := verifier.Verify(..., jwt.Leeway(5*time.Second)) + Leeway = jwt.Leeway + // MaxAge is a SignOption to set the expiration "exp", "iat" JWT standard claims. + // Can be passed as last input argument of the `Sign` function. + // + // If maxAge > second then sets expiration to the token. + // It's a helper field to set the "exp" and "iat" claim values. + // Usage: + // signer.Sign(..., jwt.MaxAge(15*time.Minute)) + MaxAge = jwt.MaxAge +) + +// Shortcuts for Signing and Verifying. +var ( + VerifyToken = jwt.Verify + VerifyEncryptedToken = jwt.VerifyEncrypted + Sign = jwt.Sign + SignEncrypted = jwt.SignEncrypted +) + +// Signature algorithm helpers. +var ( + MustLoadHMAC = jwt.MustLoadHMAC + LoadHMAC = jwt.LoadHMAC + MustLoadRSA = jwt.MustLoadRSA + LoadPrivateKeyRSA = jwt.LoadPrivateKeyRSA + LoadPublicKeyRSA = jwt.LoadPublicKeyRSA + ParsePrivateKeyRSA = jwt.ParsePrivateKeyRSA + ParsePublicKeyRSA = jwt.ParsePublicKeyRSA + MustLoadECDSA = jwt.MustLoadECDSA + LoadPrivateKeyECDSA = jwt.LoadPrivateKeyECDSA + LoadPublicKeyECDSA = jwt.LoadPublicKeyECDSA + ParsePrivateKeyECDSA = jwt.ParsePrivateKeyECDSA + ParsePublicKeyECDSA = jwt.ParsePublicKeyECDSA + MustLoadEdDSA = jwt.MustLoadEdDSA + LoadPrivateKeyEdDSA = jwt.LoadPrivateKeyEdDSA + LoadPublicKeyEdDSA = jwt.LoadPublicKeyEdDSA + ParsePrivateKeyEdDSA = jwt.ParsePrivateKeyEdDSA + ParsePublicKeyEdDSA = jwt.ParsePublicKeyEdDSA ) diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go index 377ca027..1c827969 100644 --- a/middleware/jwt/blocklist.go +++ b/middleware/jwt/blocklist.go @@ -1,9 +1,7 @@ package jwt import ( - stdContext "context" - "sync" - "time" + "github.com/kataras/jwt" ) // Blocklist should hold and manage invalidated-by-server tokens. @@ -15,136 +13,14 @@ import ( // e.g. a redis one to keep persistence of invalidated tokens on server restarts. // and bind to the JWT middleware's Blocklist field. type Blocklist interface { - // Set should upsert a token to the storage. - Set(token string, expiresAt time.Time) + jwt.TokenValidator + + // InvalidateToken should invalidate a verified JWT token. + InvalidateToken(token []byte, expiry int64) // Del should remove a token from the storage. - Del(token string) + Del(token []byte) // Count should return the total amount of tokens stored. Count() int // Has should report whether a specific token exists in the storage. - Has(token string) bool -} - -// blocklist is an in-memory storage of tokens that should be -// immediately invalidated by the server-side. -// The most common way to invalidate a token, e.g. on user logout, -// is to make the client-side remove the token itself. -// However, if someone else has access to that token, -// it could be still valid for new requests until its expiration. -type blocklist struct { - entries map[string]time.Time // key = token | value = expiration time (to remove expired). - mu sync.RWMutex -} - -// NewBlocklist returns a new up and running in-memory Token Blocklist. -// The returned value can be set to the JWT instance's Blocklist field. -func NewBlocklist(gcEvery time.Duration) Blocklist { - return NewBlocklistContext(stdContext.Background(), gcEvery) -} - -// NewBlocklistContext same as `NewBlocklist` -// but it also accepts a standard Go Context for GC cancelation. -func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) Blocklist { - b := &blocklist{ - entries: make(map[string]time.Time), - } - - if gcEvery > 0 { - go b.runGC(ctx, gcEvery) - } - - return b -} - -// Set upserts a given token, with its expiration time, -// to the block list, so it's immediately invalidated by the server-side. -func (b *blocklist) Set(token string, expiresAt time.Time) { - b.mu.Lock() - b.entries[token] = expiresAt - b.mu.Unlock() -} - -// Del removes a "token" from the block list. -func (b *blocklist) Del(token string) { - b.mu.Lock() - delete(b.entries, token) - b.mu.Unlock() -} - -// Count returns the total amount of blocked tokens. -func (b *blocklist) Count() int { - b.mu.RLock() - n := len(b.entries) - b.mu.RUnlock() - - return n -} - -// Has reports whether the given "token" is blocked by the server. -// This method is called before the token verification, -// so even if was expired it is removed from the block list. -func (b *blocklist) Has(token string) bool { - if token == "" { - return false - } - - b.mu.RLock() - _, ok := b.entries[token] - b.mu.RUnlock() - - /* No, the Blocklist will be used after the token is parsed, - there we can call the Del method if err was ErrExpired. - if ok { - // As an extra step, to keep the list size as small as possible, - // we delete it from list if it's going to be expired - // ~in the next `blockedExpireLeeway` seconds.~ - // - Let's keep it easier for testing by not setting a leeway. - // if time.Now().Add(blockedExpireLeeway).After(expiresAt) { - if time.Now().After(expiresAt) { - b.Del(token) - } - }*/ - - return ok -} - -// GC iterates over all entries and removes expired tokens. -// This method is helpful to keep the list size small. -// Depending on the application, the GC method can be scheduled -// to called every half or a whole hour. -// A good value for a GC cron task is the JWT's max age (default). -func (b *blocklist) GC() int { - now := time.Now() - var markedForDeletion []string - - b.mu.RLock() - for token, expiresAt := range b.entries { - if now.After(expiresAt) { - markedForDeletion = append(markedForDeletion, token) - } - } - b.mu.RUnlock() - - n := len(markedForDeletion) - if n > 0 { - for _, token := range markedForDeletion { - b.Del(token) - } - } - - return n -} - -func (b *blocklist) runGC(ctx stdContext.Context, every time.Duration) { - t := time.NewTicker(every) - - for { - select { - case <-ctx.Done(): - t.Stop() - return - case <-t.C: - b.GC() - } - } + Has(token []byte) bool } diff --git a/middleware/jwt/extractor.go b/middleware/jwt/extractor.go new file mode 100644 index 00000000..a04dcb98 --- /dev/null +++ b/middleware/jwt/extractor.go @@ -0,0 +1,71 @@ +package jwt + +import ( + "strings" + + "github.com/kataras/iris/v12/context" +) + +// TokenExtractor is a function that takes a context as input and returns +// a token. An empty string should be returned if no token found +// without additional information. +type TokenExtractor func(*context.Context) string + +// FromHeader is a token extractor. +// It reads the token from the Authorization request header of form: +// Authorization: "Bearer {token}". +func FromHeader(ctx *context.Context) string { + authHeader := ctx.GetHeader("Authorization") + if authHeader == "" { + return "" + } + + // pure check: authorization header format must be Bearer {token} + authHeaderParts := strings.Split(authHeader, " ") + if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { + return "" + } + + return authHeaderParts[1] +} + +// FromQuery is a token extractor. +// It reads the token from the "token" url query parameter. +func FromQuery(ctx *context.Context) string { + return ctx.URLParam("token") +} + +// FromJSON is a token extractor. +// Reads a json request body and extracts the json based on the given field. +// The request content-type should contain the: application/json header value, otherwise +// this method will not try to read and consume the body. +func FromJSON(jsonKey string) TokenExtractor { + return func(ctx *context.Context) string { + if ctx.GetContentTypeRequested() != context.ContentJSONHeaderValue { + return "" + } + + var m context.Map + ctx.RecordRequestBody(true) + defer ctx.RecordRequestBody(false) + if err := ctx.ReadJSON(&m); err != nil { + return "" + } + + if m == nil { + return "" + } + + v, ok := m[jsonKey] + if !ok { + return "" + } + + tok, ok := v.(string) + if !ok { + return "" + } + + return tok + } +} diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index a2d8172c..e2adc312 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -1,861 +1,7 @@ package jwt -import ( - "crypto" - "encoding/json" - "fmt" - "os" - "strings" - "time" - - "github.com/kataras/iris/v12/context" - - "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/jwt" -) +import "github.com/kataras/iris/v12/context" func init() { context.SetHandlerName("iris/middleware/jwt.*", "iris.jwt") } - -// TokenExtractor is a function that takes a context as input and returns -// a token. An empty string should be returned if no token found -// without additional information. -type TokenExtractor func(*context.Context) string - -// FromHeader is a token extractor. -// It reads the token from the Authorization request header of form: -// Authorization: "Bearer {token}". -func FromHeader(ctx *context.Context) string { - authHeader := ctx.GetHeader("Authorization") - if authHeader == "" { - return "" - } - - // pure check: authorization header format must be Bearer {token} - authHeaderParts := strings.Split(authHeader, " ") - if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { - return "" - } - - return authHeaderParts[1] -} - -// FromQuery is a token extractor. -// It reads the token from the "token" url query parameter. -func FromQuery(ctx *context.Context) string { - return ctx.URLParam("token") -} - -// FromJSON is a token extractor. -// Reads a json request body and extracts the json based on the given field. -// The request content-type should contain the: application/json header value, otherwise -// this method will not try to read and consume the body. -func FromJSON(jsonKey string) TokenExtractor { - return func(ctx *context.Context) string { - if ctx.GetContentTypeRequested() != context.ContentJSONHeaderValue { - return "" - } - - var m context.Map - ctx.RecordRequestBody(true) - defer ctx.RecordRequestBody(false) - if err := ctx.ReadJSON(&m); err != nil { - return "" - } - - if m == nil { - return "" - } - - v, ok := m[jsonKey] - if !ok { - return "" - } - - tok, ok := v.(string) - if !ok { - return "" - } - - return tok - } -} - -// JWT holds the necessary information the middleware need -// to sign and verify tokens. -// -// The `RSA(privateFile, publicFile, password)` package-level helper function -// can be used to decode the SignKey and VerifyKey. -// -// For an easy use look the `HMAC` package-level function -// and the its `NewUser` and `VerifyUser` methods. -type JWT struct { - // MaxAge is the expiration duration of the generated tokens. - MaxAge time.Duration - - // Extractors are used to extract a raw token string value - // from the request. - // Builtin extractors: - // * FromHeader - // * FromQuery - // * FromJSON - // Defaults to a slice of `FromHeader` and `FromQuery`. - Extractors []TokenExtractor - - // Signer is used to sign the token. - // It is set on `New` and `Default` package-level functions. - Signer jose.Signer - // VerificationKey is used to verify the token (public key). - VerificationKey interface{} - - // Encrypter is used to, optionally, encrypt the token. - // It is set on `WithEncryption` method. - Encrypter jose.Encrypter - // DecriptionKey is used to decrypt the token (private key) - DecriptionKey interface{} - - // Blocklist holds the invalidated-by-server tokens (that are not yet expired). - // It is not initialized by default. - // Initialization Usage: - // j.InitDefaultBlocklist() - // OR - // j.Blocklist = jwt.NewBlocklist(gcEveryDuration) - // Usage: - // - ctx.Logout() - // - j.Invalidate(ctx) - Blocklist Blocklist -} - -type privateKey interface{ Public() crypto.PublicKey } - -// New returns a new JWT instance. -// It accepts a maximum time duration for token expiration -// and the algorithm among with its key for signing and verification. -// -// See `WithEncryption` method to add token encryption too. -// Use `Token` method to generate a new token string -// and `VerifyToken` method to decrypt, verify and bind claims of an incoming request token. -// Token, by default, is extracted by "Authorization: Bearer {token}" request header and -// url query parameter of "token". Token extractors can be modified through the `Extractors` field. -// -// For example, if you want to sign and verify using RSA-256 key: -// 1. Generate key file, e.g: -// $ openssl genrsa -des3 -out private.pem 2048 -// 2. Read file contents with io.ReadFile("./private.pem") -// 3. Pass the []byte result to the `ParseRSAPrivateKey(contents, password)` package-level helper -// 4. Use the result *rsa.PrivateKey as "key" input parameter of this `New` function. -// -// See aliases.go file for available algorithms. -func New(maxAge time.Duration, alg SignatureAlgorithm, key interface{}) (*JWT, error) { - sig, err := jose.NewSigner(jose.SigningKey{ - Algorithm: alg, - Key: key, - }, (&jose.SignerOptions{}).WithType("JWT")) - - if err != nil { - return nil, err - } - - j := &JWT{ - Signer: sig, - VerificationKey: key, - MaxAge: maxAge, - Extractors: []TokenExtractor{FromHeader, FromQuery}, - } - - if s, ok := key.(privateKey); ok { - j.VerificationKey = s.Public() - } - - return j, nil -} - -// Default key filenames for `RSA`. -const ( - DefaultSignFilename = "jwt_sign.key" - DefaultEncFilename = "jwt_enc.key" -) - -// RSA returns a new `JWT` instance. -// It tries to parse RSA256 keys from "filenames[0]" (defaults to "jwt_sign.key") and -// "filenames[1]" (defaults to "jwt_enc.key") files or generates and exports new random keys. -// -// It panics on errors. -// Use the `New` package-level function instead for more options. -func RSA(maxAge time.Duration, filenames ...string) *JWT { - var ( - signFilename = DefaultSignFilename - encFilename = DefaultEncFilename - ) - - switch len(filenames) { - case 1: - signFilename = filenames[0] - case 2: - encFilename = filenames[1] - } - - // Do not try to create or load enc key if only sign key already exists. - withEncryption := true - if fileExists(signFilename) { - withEncryption = fileExists(encFilename) - } - - sigKey, err := LoadRSA(signFilename, 2048) - if err != nil { - panic(err) - } - - j, err := New(maxAge, RS256, sigKey) - if err != nil { - panic(err) - } - - if withEncryption { - encKey, err := LoadRSA(encFilename, 2048) - if err != nil { - panic(err) - } - err = j.WithEncryption(A128CBCHS256, RSA15, encKey) - if err != nil { - panic(err) - } - } - - return j -} - -const ( - signEnv = "JWT_SECRET" - encEnv = "JWT_SECRET_ENC" -) - -func getenv(key string, def string) string { - v := os.Getenv(key) - if v == "" { - return def - } - - return v -} - -// HMAC returns a new `JWT` instance. -// It tries to read hmac256 secret keys from system environment variables: -// * JWT_SECRET for signing and verification key and -// * JWT_SECRET_ENC for encryption and decryption key -// and defaults them to the given "keys" respectfully. -// -// It panics on errors. -// Use the `New` package-level function instead for more options. -// -// Example at: -// https://github.com/kataras/iris/tree/master/_examples/auth/jwt/overview/main.go -func HMAC(maxAge time.Duration, keys ...string) *JWT { - var defaultSignSecret, defaultEncSecret string - - switch len(keys) { - case 1: - defaultSignSecret = keys[0] - case 2: - defaultEncSecret = keys[1] - } - - signSecret := getenv(signEnv, defaultSignSecret) - encSecret := getenv(encEnv, defaultEncSecret) - - j, err := New(maxAge, HS256, []byte(signSecret)) - if err != nil { - panic(err) - } - - if encSecret != "" { - err = j.WithEncryption(A128GCM, DIRECT, []byte(encSecret)) - if err != nil { - panic(err) - } - } - - return j -} - -// WithEncryption method enables encryption and decryption of the token. -// It sets an appropriate encrypter(`Encrypter` and the `DecriptionKey` fields) based on the key type. -func (j *JWT) WithEncryption(contentEncryption ContentEncryption, alg KeyAlgorithm, key interface{}) error { - var publicKey interface{} = key - if s, ok := key.(privateKey); ok { - publicKey = s.Public() - } - - enc, err := jose.NewEncrypter(contentEncryption, jose.Recipient{ - Algorithm: alg, - Key: publicKey, - }, - (&jose.EncrypterOptions{}).WithType("JWT").WithContentType("JWT"), - ) - - if err != nil { - return err - } - - j.Encrypter = enc - j.DecriptionKey = key - return nil -} - -// InitDefaultBlocklist initializes the Blocklist field with the default in-memory implementation. -// Should be called on jwt middleware creation-time, -// after this, the developer can use the Context.Logout method -// to invalidate a verified token by the server-side. -func (j *JWT) InitDefaultBlocklist() { - gcEvery := 30 * time.Minute - if j.MaxAge > 0 { - gcEvery = j.MaxAge - } - j.Blocklist = NewBlocklist(gcEvery) -} - -// ExpiryMap adds the expiration based on the "maxAge" to the "claims" map. -// It's called automatically on `Token` method. -func ExpiryMap(maxAge time.Duration, claims context.Map) { - now := time.Now() - if claims["exp"] == nil { - claims["exp"] = NewNumericDate(now.Add(maxAge)) - } - - if claims["iat"] == nil { - claims["iat"] = NewNumericDate(now) - } -} - -// Token generates and returns a new token string. -// See `VerifyToken` too. -func (j *JWT) Token(claims interface{}) (string, error) { - return j.token(j.MaxAge, claims) -} - -func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { - if claims == nil { - return "", ErrInvalidKey - } - - c, nErr := normalize(claims) - if nErr != nil { - return "", nErr - } - - ExpiryMap(maxAge, c) - - var ( - token string - err error - ) - // jwt.Builder and jwt.NestedBuilder contain same methods but they are not the same. - // - // Note that the .Claims method there, converts a Struct to a map under the hoods. - // That means that we will not have any performance cost - // if we do it by ourselves and pass always a Map there. - // That gives us the option to allow user to pass ANY go struct - // and we can add the "exp", "nbf", "iat" map values by ourselves - // based on the j.MaxAge. - // (^ done, see normalize, all methods are - // changed to accept totally custom types, no need to embed the standard Claims anymore). - if j.DecriptionKey != nil { - token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(c).CompactSerialize() - } else { - token, err = jwt.Signed(j.Signer).Claims(c).CompactSerialize() - // payload, pErr := Marshal(c) - // if pErr != nil { - // return "", pErr - // } - // sign, sErr := j.Signer.Sign(payload) - // if sErr != nil { - // return "", sErr - // } - - // token, err = sign.CompactSerialize() - } - - if err != nil { - return "", err - } - - return token, nil -} - -// WriteToken is a helper which just generates(calls the `Token` method) and writes -// a new token to the client in plain text format. -// -// Use the `Token` method to get a new generated token raw string value. -func (j *JWT) WriteToken(ctx *context.Context, claims interface{}) error { - token, err := j.Token(claims) - if err != nil { - ctx.StatusCode(500) - return err - } - - _, err = ctx.WriteString(token) - return err -} - -// VerifyToken verifies (and decrypts) the request token, -// it also validates and binds the parsed token's claims to the "claimsPtr" (destination). -// -// The last, variadic, input argument is optionally, if provided then the -// parsed claims must match the expectations; -// e.g. Audience, Issuer, ID, Subject. -// See `ExpectXXX` package-functions for details. -func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { - token := j.RequestToken(ctx) - return j.VerifyTokenString(ctx, token, claimsPtr, expectations...) -} - -// VerifyRefreshToken like the `VerifyToken` but it verifies a refresh token one instead. -// If the implementation does not fill the application's requirements, -// you can ignore this method and still use the `VerifyToken` for refresh tokens too. -// -// This method adds the ExpectRefreshToken expectation and it -// tries to read the refresh token from raw body or, -// if content type was application/json, then it extracts the token -// from the JSON request body's {"refresh_token": "$token"} key. -func (j *JWT) VerifyRefreshToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { - token := j.RequestToken(ctx) - if token == "" { - ctx.RecordRequestBody(true) - defer ctx.RecordRequestBody(false) - - var tokenPair TokenPair // read "refresh_token" from JSON. - if ctx.GetContentTypeRequested() == context.ContentJSONHeaderValue { - ctx.ReadJSON(&tokenPair) // ignore error. - token = tokenPair.RefreshToken - if token == "" { - return nil, ErrMissing - } - } else { - ctx.ReadBody(&token) - } - } - - return j.VerifyTokenString(ctx, token, claimsPtr, append(expectations, ExpectRefreshToken)...) -} - -// RequestToken extracts the token from the request. -func (j *JWT) RequestToken(ctx *context.Context) (token string) { - for _, extract := range j.Extractors { - if token = extract(ctx); token != "" { - break // ok we found it. - } - } - - return -} - -// TokenSetter is an interface which if implemented -// the extracted, verified, token is stored to the object. -type TokenSetter interface { - SetToken(token string) -} - -// TokenInfo holds the standard token information may required -// for further actions. -// This structure is mostly useful when the developer's go structure -// does not hold the standard jwt fields (e.g. "exp") -// but want access to the parsed token which contains those fields. -// Inside the middleware, it is used to invalidate tokens through server-side, see `Invalidate`. -type TokenInfo struct { - RequestToken string // The request token. - Claims Claims // The standard JWT parsed fields from the request Token. - Value interface{} // The pointer to the end-developer's custom claims structure (see `Get`). -} - -const tokenInfoContextKey = "iris.jwt.token" - -// Get returns the verified developer token claims. -// -// -// Usage: -// j := jwt.New(...) -// app.Use(j.Verify(func() interface{} { return new(CustomClaims) })) -// app.Post("/restricted", func(ctx iris.Context){ -// claims := jwt.Get(ctx).(*CustomClaims) -// [use claims...] -// }) -// -// Note that there is one exception, if the value was a pointer -// to a map[string]interface{}, it returns the map itself so it can be -// accessible directly without the requirement of unwrapping it, e.g. -// j.Verify(func() interface{} { -// return &iris.Map{} -// } -// [...] -// claims := jwt.Get(ctx).(iris.Map) -func Get(ctx *context.Context) interface{} { - if tok := GetTokenInfo(ctx); tok != nil { - switch v := tok.Value.(type) { - case *context.Map: - return *v - case *json.RawMessage: - // This is useful when we can accept more than one - // type of JWT token in the same request path, - // but we also want to keep type safety. - // Usage: - // type myClaims struct { Roles []string `json:"roles"`} - // v := jwt.Get(ctx) - // var claims myClaims - // jwt.Unmarshal(v, &claims) - // [...claims.Roles] - return *v - default: - return v - } - } - - return nil -} - -// GetTokenInfo returns the verified token's information. -func GetTokenInfo(ctx *context.Context) *TokenInfo { - if v := ctx.Values().Get(tokenInfoContextKey); v != nil { - if t, ok := v.(*TokenInfo); ok { - return t - } - } - - return nil -} - -// Invalidate invalidates a verified JWT token. -// It adds the request token, retrieved by Verify methods, to the block list. -// Next request will be blocked, even if the token was not yet expired. -// This method can be used when the client-side does not clear the token -// on a user logout operation. -// -// Note: the Blocklist should be initialized before serve-time: j.InitDefaultBlocklist(). -func (j *JWT) Invalidate(ctx *context.Context) { - if j.Blocklist == nil { - ctx.Application().Logger().Debug("jwt.Invalidate: Blocklist is nil") - return - } - - tokenInfo := GetTokenInfo(ctx) - if tokenInfo == nil { - return - } - - j.Blocklist.Set(tokenInfo.RequestToken, tokenInfo.Claims.Expiry.Time()) -} - -// VerifyTokenString verifies and unmarshals an extracted request token to "dest" destination. -// The last variadic input indicates any further validations against the verified token claims. -// If the given "dest" is a valid context.User then ctx.User() will return it. -// If the token is missing an `ErrMissing` is returned. -// If the incoming token was expired an `ErrExpired` is returned. -// If the incoming token was blocked by the server an `ErrBlocked` is returned. -func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interface{}, expectations ...Expectation) (*TokenInfo, error) { - if token == "" { - return nil, ErrMissing - } - - var ( - parsedToken *jwt.JSONWebToken - err error - ) - - if j.DecriptionKey != nil { - t, cerr := jwt.ParseSignedAndEncrypted(token) - if cerr != nil { - return nil, cerr - } - - parsedToken, err = t.Decrypt(j.DecriptionKey) - } else { - parsedToken, err = jwt.ParseSigned(token) - } - if err != nil { - return nil, err - } - - var ( - claims Claims - tokenMaxAger tokenWithMaxAge - ) - - var ( - ignoreDest = dest == nil - ignoreVarClaims bool - ) - if !ignoreDest { // if dest was not nil, check if the dest is already a standard claims pointer. - _, ignoreVarClaims = dest.(*Claims) - } - - // Ensure read the standard claims one if dest was Claims or was nil. - // (it wont break anything if we unmarshal them twice though, we just do it for performance reasons). - var pointers = []interface{}{&tokenMaxAger} - if !ignoreDest { - pointers = append(pointers, dest) - } - if !ignoreVarClaims { - pointers = append(pointers, &claims) - } - if err = parsedToken.Claims(j.VerificationKey, pointers...); err != nil { - return nil, err - } - - // Set the std claims, if missing from receiver so the expectations and validation still work. - if ignoreVarClaims { - claims = *dest.(*Claims) - } else if ignoreDest { - dest = &claims - } - - 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. - // No need for more interfaces. - - err = validateClaims(ctx, dest, claims, expected) - if err != nil { - if err == ErrExpired { - // If token was expired remove it from the block list. - if j.Blocklist != nil { - j.Blocklist.Del(token) - } - } - - return nil, err - } - - if j.Blocklist != nil { - // If token exists in the block list, then stop here. - if j.Blocklist.Has(token) { - return nil, ErrBlocked - } - } - - if !ignoreDest { - if ut, ok := dest.(TokenSetter); ok { - // The u.Token is empty even if we set it and export it on JSON structure. - // Set it manually. - ut.SetToken(token) - } - } - - // Set the information. - tokenInfo := &TokenInfo{ - RequestToken: token, - Claims: claims, - Value: dest, - } - - return tokenInfo, nil -} - -// TokenPair holds the access token and refresh token response. -type TokenPair struct { - AccessToken string `json:"access_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. -// 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 - } - - 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 - } - - pair := TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - } - - return pair, nil -} - -// Verify returns a middleware which -// decrypts an incoming request token to the result of the given "newPtr". -// It does write a 401 unauthorized status code if verification or decryption failed. -// It calls the `ctx.Next` on verified requests. -// -// Iit unmarshals the token to the specific type returned from the given "newPtr" function. -// It sets the Context User and User's Token too. So the next handler(s) -// of the same chain can access the User through a `Context.User()` call. -// -// Note unlike `VerifyToken`, this method automatically protects -// the claims with JSON required tags (see `MeetRequirements` Expection). -// -// On verified tokens: -// - The information can be retrieved through `Get` and `GetTokenInfo` functions. -// - User is set if the newPtr returns a valid Context User -// - The Context Logout method is set if Blocklist was initialized -// Any error is captured to the Context, -// which can be retrieved by a `ctx.GetErr()` call. -// -// See `VerifyJSON` too. -func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) context.Handler { - if newPtr == nil { - newPtr = func() interface{} { - // Return a map here as the default type one, - // as it does allow .Get callers to access its fields with ease - // (although, I always recommend using structs for type-safety and - // also they can accept a required tag option too). - return &context.Map{} - } - } - - expections = append(expections, MeetRequirements(newPtr())) - - return func(ctx *context.Context) { - ptr := newPtr() - - tokenInfo, err := j.VerifyToken(ctx, ptr, expections...) - if err != nil { - ctx.Application().Logger().Debugf("iris.jwt.Verify: %v", err) - ctx.StopWithError(401, context.PrivateError(err)) - return - } - - if u, ok := ptr.(context.User); ok { - ctx.SetUser(u) - } - - if j.Blocklist != nil { - ctx.SetLogoutFunc(j.Invalidate) - } - - ctx.Values().Set(tokenInfoContextKey, tokenInfo) - ctx.Next() - } -} - -// VerifyMap is a shortcut of Verify with a function which will bind -// the claims to a standard Go map[string]interface{}. -func (j *JWT) VerifyMap(expections ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return &context.Map{} - }, expections...) -} - -// VerifyJSON works like `Verify` but instead it -// binds its "newPtr" function to return a raw JSON message. -// It does NOT read the token from JSON by itself, -// to do that add the `FromJSON` to the Token Extractors. -// It's used to bind the claims in any value type on the next handler. -// -// This allows the caller to bind this JSON message to any Go structure (or map). -// This is useful when we can accept more than one -// type of JWT token in the same request path, -// but we also want to keep type safety. -// Usage: -// app.Use(jwt.VerifyJSON()) -// Inside a route Handler: -// claims := struct { Roles []string `json:"roles"`}{} -// jwt.ReadJSON(ctx, &claims) -// ...access to claims.Roles as []string -func (j *JWT) VerifyJSON(expections ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return new(json.RawMessage) - }, expections...) -} - -// ReadJSON is a helper which binds "claimsPtr" to the -// raw JSON token claims. -// Use inside the handlers when `VerifyJSON()` middleware was registered. -func ReadJSON(ctx *context.Context, claimsPtr interface{}) error { - v := Get(ctx) - if v == nil { - return ErrMissing - } - data, ok := v.(json.RawMessage) - if !ok { - return ErrMissing - } - return Unmarshal(data, claimsPtr) -} - -// NewUser returns a new User based on the given "opts". -// The caller can modify the User until its `GetToken` is called. -func (j *JWT) NewUser(opts ...UserOption) *User { - u := &User{ - j: j, - SimpleUser: &context.SimpleUser{ - Authorization: "IRIS_JWT_USER", // Used to separate a refresh token with a user/access one too. - Features: []context.UserFeature{ - context.TokenFeature, - }, - }, - } - - for _, opt := range opts { - opt(u) - } - - return u -} - -// VerifyUser works like the `Verify` method but instead -// it unmarshals the token to the specific User type. -// It sets the Context User too. So the next handler(s) -// of the same chain can access the User through a `Context.User()` call. -func (j *JWT) VerifyUser(expectations ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return new(User) - }, expectations...) -} diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index 277ce7bb..b65c02b7 100644 --- a/middleware/jwt/jwt_test.go +++ b/middleware/jwt/jwt_test.go @@ -1,8 +1,7 @@ -// Package jwt_test contains simple Iris jwt tests. Most of the jwt functionality is already tested inside the jose package itself. package jwt_test import ( - "os" + "fmt" "testing" "time" @@ -11,324 +10,56 @@ import ( "github.com/kataras/iris/v12/middleware/jwt" ) -type userClaims struct { - // Optionally: - Issuer string `json:"iss"` - Subject string `json:"sub"` - Audience jwt.Audience `json:"aud"` - // - Username string `json:"username"` +var testAlg, testSecret = jwt.HS256, []byte("sercrethatmaycontainch@r$") + +type fooClaims struct { + Foo string `json:"foo"` } -const testMaxAge = 7 * time.Second - -// Random RSA verification and encryption. -func TestRSA(t *testing.T) { - j := jwt.RSA(testMaxAge) - t.Cleanup(func() { - os.Remove(jwt.DefaultSignFilename) - os.Remove(jwt.DefaultEncFilename) - }) - testWriteVerifyBlockToken(t, j) -} - -// HMAC verification and encryption. -func TestHMAC(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - testWriteVerifyBlockToken(t, j) -} - -func TestNew_HMAC(t *testing.T) { - j, err := jwt.New(testMaxAge, jwt.HS256, []byte("secret")) - if err != nil { - t.Fatal(err) - } - err = j.WithEncryption(jwt.A128GCM, jwt.DIRECT, []byte("itsa16bytesecret")) - if err != nil { - t.Fatal(err) - } - - testWriteVerifyBlockToken(t, j) -} - -// HMAC verification only (unecrypted). -func TestVerify(t *testing.T) { - j, err := jwt.New(testMaxAge, jwt.HS256, []byte("another secret")) - if err != nil { - t.Fatal(err) - } - testWriteVerifyBlockToken(t, j) -} - -func testWriteVerifyBlockToken(t *testing.T, j *jwt.JWT) { - t.Helper() - - j.InitDefaultBlocklist() - j.Extractors = append(j.Extractors, jwt.FromJSON("access_token")) - - customClaims := &userClaims{ - Issuer: "an-issuer", - Audience: jwt.Audience{"an-audience"}, - Subject: "user", - Username: "kataras", - } - +// The actual tests are inside the kataras/jwt repository. +// This runs simple checks of just the middleware part. +func TestJWT(t *testing.T) { app := iris.New() - app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { - if err := ctx.GetErr(); err != nil { - // Test accessing the private error and set this as the response body. - ctx.WriteString(err.Error()) - } else { // Else the default behavior - ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) - } - }) - app.Get("/auth", func(ctx iris.Context) { - j.WriteToken(ctx, customClaims) - }) - - app.Post("/protected", func(ctx iris.Context) { - var claims userClaims - _, err := j.VerifyToken(ctx, &claims) - if err != nil { - // t.Logf("%s: %v", ctx.Path(), err) - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - - ctx.JSON(claims) - }) - - m := app.Party("/middleware") - m.Use(j.Verify(func() interface{} { - return new(userClaims) - })) - m.Post("/protected", func(ctx iris.Context) { - claims := jwt.Get(ctx) - ctx.JSON(claims) - }) - m.Post("/invalidate", func(ctx iris.Context) { - ctx.Logout() // OR j.Invalidate(ctx) - }) - - e := httptest.New(t, app) - - // Get token. - rawToken := e.GET("/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if rawToken == "" { - t.Fatalf("empty token") - } - - restrictedPaths := [...]string{"/protected", "/middleware/protected"} - - now := time.Now() - for _, path := range restrictedPaths { - // Authorization Header. - e.POST(path).WithHeader("Authorization", "Bearer "+rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // URL Query. - e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // JSON Body. - e.POST(path).WithJSON(iris.Map{"access_token": rawToken}).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // Missing "Bearer". - e.POST(path).WithHeader("Authorization", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is missing") - } - - // Invalidate the token. - e.POST("/middleware/invalidate").WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK) - // Token is blocked by server. - e.POST("/middleware/protected").WithQuery("token", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is blocked") - - expireRemDur := testMaxAge - time.Since(now) - - // Expiration. - time.Sleep(expireRemDur /* -end */) - for _, path := range restrictedPaths { - e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is expired (exp)") - } -} - -func TestVerifyMap(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - expectedClaims := iris.Map{ - "iss": "tester", - "username": "makis", - "roles": []string{"admin"}, - } - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, expectedClaims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - if expectedClaims["exp"] == nil || expectedClaims["iat"] == nil { - ctx.StopWithText(iris.StatusBadRequest, - "exp or/and iat is nil - this means that the expiry was not set") - return - } - }) - - userAPI := app.Party("/user") - userAPI.Post("/", func(ctx iris.Context) { - var claims iris.Map - if _, err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - - ctx.JSON(claims) - }) - - // Test map + Verify middleware. - userAPI.Post("/middleware", j.Verify(nil), func(ctx iris.Context) { - claims := jwt.Get(ctx) - ctx.JSON(claims) - }) - - e := httptest.New(t, app, httptest.LogLevel("error")) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - e.POST("/user/middleware").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - e.POST("/user").Expect().Status(httptest.StatusUnauthorized) -} - -type customClaims struct { - Username string `json:"username"` - Token string `json:"token"` -} - -func (c *customClaims) SetToken(tok string) { - c.Token = tok -} - -func TestVerifyStruct(t *testing.T) { - maxAge := testMaxAge / 2 - j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, customClaims{ - Username: "makis", - }) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - }) - - userAPI := app.Party("/user") - userAPI.Post("/", func(ctx iris.Context) { - var claims customClaims - if _, err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - - ctx.JSON(claims) - }) - - e := httptest.New(t, app) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Object().ContainsMap(iris.Map{ - "username": "makis", - "token": token, // Test SetToken. - }) - - e.POST("/user").Expect().Status(httptest.StatusUnauthorized) - time.Sleep(maxAge) - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect().Status(httptest.StatusUnauthorized) -} - -func TestVerifyJSON(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, iris.Map{"roles": []string{"admin"}}) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - }) - - app.Post("/", j.VerifyJSON(), func(ctx iris.Context) { - claims := struct { - Roles []string `json:"roles"` - }{} - jwt.ReadJSON(ctx, &claims) - ctx.JSON(claims) - }) - - e := httptest.New(t, app, httptest.LogLevel("error")) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - - e.POST("/").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(iris.Map{"roles": []string{"admin"}}) - - e.POST("/").Expect().Status(httptest.StatusUnauthorized) -} - -func TestVerifyUserAndExpected(t *testing.T) { // Tests the jwt.User struct + context validator + expected. - maxAge := testMaxAge / 2 - j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") - expectedUser := j.NewUser(jwt.Username("makis"), jwt.Roles("admin"), jwt.Fields(iris.Map{ - "custom": true, - })) // only for the sake of the test, we iniitalize it here. - expectedUser.Issuer = "tester" - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - tok, err := expectedUser.GetToken() + signer := jwt.NewSigner(testAlg, testSecret, 3*time.Second) + app.Get("/", func(ctx iris.Context) { + claims := fooClaims{Foo: "bar"} + token, err := signer.Sign(claims) if err != nil { ctx.StopWithError(iris.StatusInternalServerError, err) return } - ctx.WriteString(tok) + ctx.Write(token) }) - userAPI := app.Party("/user") - userAPI.Use(jwt.WithExpected(jwt.Expected{Issuer: "tester"}, j.VerifyUser())) - userAPI.Post("/", func(ctx iris.Context) { - user := ctx.User() - ctx.JSON(user) + verifier := jwt.NewVerifier(testAlg, testSecret) + verifier.ErrorHandler = func(ctx iris.Context, err error) { // app.OnErrorCode(401, ...) + ctx.StopWithError(iris.StatusUnauthorized, err) + } + middleware := verifier.Verify(func() interface{} { return new(fooClaims) }) + app.Get("/protected", middleware, func(ctx iris.Context) { + claims := jwt.Get(ctx).(*fooClaims) + ctx.WriteString(claims.Foo) }) e := httptest.New(t, app) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedUser) + // Get generated token. + token := e.GET("/").Expect().Status(iris.StatusOK).Body().Raw() + // Test Header. + headerValue := fmt.Sprintf("Bearer %s", token) + e.GET("/protected").WithHeader("Authorization", headerValue).Expect(). + Status(iris.StatusOK).Body().Equal("bar") + // Test URL query. + e.GET("/protected").WithQuery("token", token).Expect(). + Status(iris.StatusOK).Body().Equal("bar") - // Test generic client message if we don't manage the private error by ourselves. - e.POST("/user").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + // Test unauthorized. + e.GET("/protected").Expect().Status(iris.StatusUnauthorized) + e.GET("/protected").WithHeader("Authorization", "missing bearer").Expect().Status(iris.StatusUnauthorized) + e.GET("/protected").WithQuery("token", "invalid_token").Expect().Status(iris.StatusUnauthorized) + // Test expired (note checks happen based on second round). + time.Sleep(5 * time.Second) + e.GET("/protected").WithHeader("Authorization", headerValue).Expect(). + Status(iris.StatusUnauthorized).Body().Equal("token expired") } diff --git a/middleware/jwt/rsa_util.go b/middleware/jwt/rsa_util.go deleted file mode 100644 index f40f69ab..00000000 --- a/middleware/jwt/rsa_util.go +++ /dev/null @@ -1,106 +0,0 @@ -package jwt - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "errors" - "io/ioutil" - "os" -) - -// LoadRSA tries to read RSA Private Key from "fname" system file, -// if does not exist then it generates a new random one based on "bits" (e.g. 2048, 4096) -// and exports it to a new "fname" file. -func LoadRSA(fname string, bits int) (key *rsa.PrivateKey, err error) { - exists := fileExists(fname) - if exists { - key, err = importFromFile(fname) - } else { - key, err = rsa.GenerateKey(rand.Reader, bits) - } - - if err != nil { - return - } - - if !exists { - err = exportToFile(key, fname) - } - - return -} - -func exportToFile(key *rsa.PrivateKey, filename string) error { - b := x509.MarshalPKCS1PrivateKey(key) - encoded := pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: b, - }, - ) - - return ioutil.WriteFile(filename, encoded, 0600) -} - -func importFromFile(filename string) (*rsa.PrivateKey, error) { - b, err := ioutil.ReadFile(filename) - if err != nil { - return nil, err - } - - return ParseRSAPrivateKey(b, nil) -} - -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -var ( - // ErrNotPEM is an error type of the `ParseXXX` function(s) fired - // when the data are not PEM-encoded. - ErrNotPEM = errors.New("key must be PEM encoded") - // ErrInvalidKey is an error type of the `ParseXXX` function(s) fired - // when the contents are not type of rsa private key. - ErrInvalidKey = errors.New("key is not of type *rsa.PrivateKey") -) - -// ParseRSAPrivateKey encodes a PEM-encoded PKCS1 or PKCS8 private key protected with a password. -func ParseRSAPrivateKey(key, password []byte) (*rsa.PrivateKey, error) { - block, _ := pem.Decode(key) - if block == nil { - return nil, ErrNotPEM - } - - var ( - parsedKey interface{} - err error - ) - - var blockDecrypted []byte - if len(password) > 0 { - if blockDecrypted, err = x509.DecryptPEMBlock(block, password); err != nil { - return nil, err - } - } else { - blockDecrypted = block.Bytes - } - - if parsedKey, err = x509.ParsePKCS1PrivateKey(blockDecrypted); err != nil { - if parsedKey, err = x509.ParsePKCS8PrivateKey(blockDecrypted); err != nil { - return nil, err - } - } - - privateKey, ok := parsedKey.(*rsa.PrivateKey) - if !ok { - return nil, ErrInvalidKey - } - - return privateKey, nil -} diff --git a/middleware/jwt/signer.go b/middleware/jwt/signer.go new file mode 100644 index 00000000..c2243d0a --- /dev/null +++ b/middleware/jwt/signer.go @@ -0,0 +1,59 @@ +package jwt + +import ( + "fmt" + "time" + + "github.com/kataras/jwt" +) + +type Signer struct { + Alg Alg + Key interface{} + + MaxAge time.Duration + + Encrypt func([]byte) ([]byte, error) +} + +func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) *Signer { + return &Signer{ + Alg: signatureAlg, + Key: signatureKey, + MaxAge: maxAge, + } +} + +// WithGCM enables AES-GCM payload decryption. +func (s *Signer) WithGCM(key, additionalData []byte) *Signer { + encrypt, _, err := jwt.GCM(key, additionalData) + if err != nil { + panic(err) // important error before serve, stop everything. + } + + s.Encrypt = encrypt + return s +} + +func (s *Signer) Sign(claims interface{}, opts ...SignOption) ([]byte, error) { + return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, append([]SignOption{MaxAge(s.MaxAge)}, opts...)...) +} + +func (s *Signer) NewTokenPair(accessClaims interface{}, refreshClaims interface{}, refreshMaxAge time.Duration, accessOpts ...SignOption) (TokenPair, error) { + if refreshMaxAge <= s.MaxAge { + return TokenPair{}, fmt.Errorf("refresh max age should be bigger than access token's one[%d - %d]", refreshMaxAge, s.MaxAge) + } + + accessToken, err := s.Sign(accessClaims, accessOpts...) + if err != nil { + return TokenPair{}, err + } + + refreshToken, err := Sign(s.Alg, s.Key, refreshClaims, MaxAge(refreshMaxAge)) + if err != nil { + return TokenPair{}, err + } + + tokenPair := jwt.NewTokenPair(accessToken, refreshToken) + return tokenPair, nil +} diff --git a/middleware/jwt/user.go b/middleware/jwt/user.go deleted file mode 100644 index 3d8af27f..00000000 --- a/middleware/jwt/user.go +++ /dev/null @@ -1,187 +0,0 @@ -package jwt - -import ( - "time" - - "github.com/kataras/iris/v12/context" -) - -// User a common User structure for JWT. -// However, we're not limited to that one; -// any Go structure can be generated as a JWT token. -// -// Look `NewUser` and `VerifyUser` JWT middleware's methods. -// Use its `GetToken` method to generate the token when -// the User structure is set. -type User struct { - Claims - // Note: we could use a map too as the Token is generated when GetToken is called. - *context.SimpleUser - - j *JWT -} - -var ( - _ context.FeaturedUser = (*User)(nil) - _ TokenSetter = (*User)(nil) - _ ContextValidator = (*User)(nil) -) - -// UserOption sets optional fields for a new User -// See `NewUser` instance function. -type UserOption func(*User) - -// Username sets the Username and the JWT Claim's Subject -// to the given "username". -func Username(username string) UserOption { - return func(u *User) { - u.Username = username - u.Claims.Subject = username - u.Features = append(u.Features, context.UsernameFeature) - } -} - -// Email sets the Email field for the User field. -func Email(email string) UserOption { - return func(u *User) { - u.Email = email - u.Features = append(u.Features, context.EmailFeature) - } -} - -// Roles upserts to the User's Roles field. -func Roles(roles ...string) UserOption { - return func(u *User) { - u.Roles = roles - u.Features = append(u.Features, context.RolesFeature) - } -} - -// MaxAge sets claims expiration and the AuthorizedAt User field. -func MaxAge(maxAge time.Duration) UserOption { - return func(u *User) { - now := time.Now() - u.Claims.Expiry = NewNumericDate(now.Add(maxAge)) - u.Claims.IssuedAt = NewNumericDate(now) - u.AuthorizedAt = now - - u.Features = append(u.Features, context.AuthorizedAtFeature) - } -} - -// Fields copies the "fields" to the user's Fields field. -// This can be used to set custom fields to the User instance. -func Fields(fields context.Map) UserOption { - return func(u *User) { - if len(fields) == 0 { - return - } - - if u.Fields == nil { - u.Fields = make(context.Map, len(fields)) - } - - for k, v := range fields { - u.Fields[k] = v - } - - u.Features = append(u.Features, context.FieldsFeature) - } -} - -// SetToken is called automaticaly on VerifyUser/VerifyObject. -// It sets the extracted from request, and verified from server raw token. -func (u *User) SetToken(token string) { - u.Token = token -} - -// GetToken overrides the SimpleUser's Token -// and returns the jwt generated token, among with -// a generator error, if any. -func (u *User) GetToken() (string, error) { - if u.Token != "" { - return u.Token, nil - } - - if u.j != nil { // it's always not nil. - if u.j.MaxAge > 0 { - // if the MaxAge option was not manually set, resolve it from the JWT instance. - MaxAge(u.j.MaxAge)(u) - } - - // we could generate a token here - // but let's do it on GetToken - // as the user fields may change - // by the caller manually until the token - // sent to the client. - tok, err := u.j.Token(u) - if err != nil { - return "", err - } - - u.Token = tok - } - - if u.Token == "" { - return "", ErrMissing - } - - return u.Token, nil -} - -// Validate validates the current user's claims against -// the request. It's called automatically by the JWT instance. -func (u *User) Validate(ctx *context.Context, claims Claims, e Expected) error { - err := u.Claims.ValidateWithLeeway(e, 0) - if err != nil { - return err - } - - if u.SimpleUser.Authorization != "IRIS_JWT_USER" { - return ErrInvalidKey - } - - // We could add specific User Expectations (new struct and accept an interface{}), - // but for the sake of code simplicity we don't, unless is requested, as the caller - // can validate specific fields by its own at the next step. - return nil -} - -// UnmarshalJSON implements the json unmarshaler interface. -func (u *User) UnmarshalJSON(data []byte) error { - err := Unmarshal(data, &u.Claims) - if err != nil { - return err - } - simpleUser := new(context.SimpleUser) - err = Unmarshal(data, simpleUser) - if err != nil { - return err - } - u.SimpleUser = simpleUser - return nil -} - -// MarshalJSON implements the json marshaler interface. -func (u *User) MarshalJSON() ([]byte, error) { - claimsB, err := Marshal(u.Claims) - if err != nil { - return nil, err - } - - userB, err := Marshal(u.SimpleUser) - if err != nil { - return nil, err - } - - if len(userB) == 0 { - return claimsB, nil - } - - claimsB = claimsB[0 : len(claimsB)-1] // remove last '}' - userB = userB[1:] // remove first '{' - - raw := append(claimsB, ',') - raw = append(raw, userB...) - return raw, nil -} diff --git a/middleware/jwt/validation.go b/middleware/jwt/validation.go deleted file mode 100644 index 9b5b6964..00000000 --- a/middleware/jwt/validation.go +++ /dev/null @@ -1,258 +0,0 @@ -package jwt - -import ( - "bytes" - "errors" - "reflect" - "strings" - "time" - - "github.com/kataras/iris/v12/context" - - "github.com/square/go-jose/v3/json" - // Use this package instead of the standard encoding/json - // to marshal the NumericDate as expected by the implementation (see 'normalize`). - "github.com/square/go-jose/v3/jwt" -) - -const ( - claimsExpectedContextKey = "iris.jwt.claims.expected" - needsValidationContextKey = "iris.jwt.claims.unvalidated" -) - -var ( - // 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 (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)") - // ErrNotValidYet indicates that token is used before time indicated in nbf claim. - ErrNotValidYet = errors.New("token not valid yet (nbf)") - // 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 (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 -// an extra layer of token validation, a claims type protection. -// See `VerifyToken` method. -type Expectation func(e *Expected, claims interface{}) error - -// Expect protects the claims with the expected values. -func Expect(expected Expected) Expectation { - return func(e *Expected, _ interface{}) error { - *e = expected - return nil - } -} - -// ExpectID protects the claims with an ID validation. -func ExpectID(id string) Expectation { - return func(e *Expected, _ interface{}) error { - e.ID = id - return nil - } -} - -// ExpectIssuer protects the claims with an issuer validation. -func ExpectIssuer(issuer string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Issuer = issuer - return nil - } -} - -// ExpectSubject protects the claims with a subject validation. -func ExpectSubject(sub string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Subject = sub - return nil - } -} - -// ExpectAudience protects the claims with an audience validation. -func ExpectAudience(audience ...string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Audience = audience - return nil - } -} - -// 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. -// -// Usage: -// Verify/VerifyToken(... MeetRequirements(MyUser{})) -func MeetRequirements(claimsType interface{}) Expectation { - // pre-calculate if we need to use reflection at serve time to check for required fields, - // this can work as an alternative of expections for custom non-standard JWT fields. - requireFieldsIndexes := getRequiredFieldIndexes(claimsType) - - return func(e *Expected, claims interface{}) error { - if len(requireFieldsIndexes) > 0 { - val := reflect.Indirect(reflect.ValueOf(claims)) - for _, idx := range requireFieldsIndexes { - field := val.Field(idx) - if field.IsZero() { - return ErrMissingKey - } - } - } - - return nil - } -} - -// WithExpected is a middleware wrapper. It wraps a VerifyXXX middleware -// with expected claims fields protection. -// Usage: -// jwt.WithExpected(jwt.Expected{Issuer:"app"}, j.VerifyUser) -func WithExpected(e Expected, verifyHandler context.Handler) context.Handler { - return func(ctx *context.Context) { - ctx.Values().Set(claimsExpectedContextKey, e) - verifyHandler(ctx) - } -} - -// ContextValidator validates the object based on the given -// claims and the expected once. The end-developer -// can use this method for advanced validations based on the request Context. -type ContextValidator interface { - Validate(ctx *context.Context, claims Claims, e Expected) error -} - -func validateClaims(ctx *context.Context, dest interface{}, claims Claims, expected Expected) (err error) { - // Get any dynamic expectation set by prior middleware. - // See `WithExpected` middleware. - if v := ctx.Values().Get(claimsExpectedContextKey); v != nil { - if e, ok := v.(Expected); ok { - expected = e - } - } - // Force-set the time, it's important for expiration. - expected.Time = time.Now() - switch c := dest.(type) { - case Claims: - err = c.ValidateWithLeeway(expected, 0) - case ContextValidator: - err = c.Validate(ctx, claims, expected) - case *context.Map: - // if the dest is a map then set automatically the expiration settings here, - // so the caller can work further with it. - err = claims.ValidateWithLeeway(expected, 0) - if err == nil { - (*c)["exp"] = claims.Expiry - (*c)["iat"] = claims.IssuedAt - if claims.NotBefore != nil { - (*c)["nbf"] = claims.NotBefore - } - } - default: - err = claims.ValidateWithLeeway(expected, 0) - } - - if err != nil { - switch err { - case jwt.ErrExpired: - return ErrExpired - case jwt.ErrNotValidYet: - return ErrNotValidYet - case jwt.ErrIssuedInTheFuture: - return ErrIssuedInTheFuture - } - } - - return err -} - -func normalize(i interface{}) (context.Map, error) { - if m, ok := i.(context.Map); ok { - return m, nil - } - - m := make(context.Map) - - raw, err := json.Marshal(i) - if err != nil { - return nil, err - } - - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - - if err := d.Decode(&m); err != nil { - return nil, err - } - - return m, nil -} - -func getRequiredFieldIndexes(i interface{}) (v []int) { - val := reflect.Indirect(reflect.ValueOf(i)) - typ := val.Type() - if typ.Kind() != reflect.Struct { - return nil - } - - for i := 0; i < val.NumField(); i++ { - field := typ.Field(i) - // Note: for the sake of simplicity we don't lookup for nested objects (FieldByIndex), - // we could do that as we do in dependency injection feature but unless requirested we don't. - tag := field.Tag.Get("json") - if strings.Contains(tag, ",required") { - v = append(v, i) - } - } - - 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 -} diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go new file mode 100644 index 00000000..7438c921 --- /dev/null +++ b/middleware/jwt/verifier.go @@ -0,0 +1,210 @@ +package jwt + +import ( + "reflect" + "time" + + "github.com/kataras/iris/v12/context" + + "github.com/kataras/jwt" +) + +const ( + claimsContextKey = "iris.jwt.claims" + verifiedTokenContextKey = "iris.jwt.token" +) + +// Get returns the claims decoded by a verifier. +func Get(ctx *context.Context) interface{} { + if v := ctx.Values().Get(claimsContextKey); v != nil { + return v + } + + return nil +} + +// GetVerifiedToken returns the verified token structure +// which holds information about the decoded token +// and its standard claims. +func GetVerifiedToken(ctx *context.Context) *VerifiedToken { + if v := ctx.Values().Get(verifiedTokenContextKey); v != nil { + if tok, ok := v.(*VerifiedToken); ok { + return tok + } + } + + return nil +} + +// Verifier holds common options to verify an incoming token. +// Its Verify method can be used as a middleware to allow authorized clients to access an API. +type Verifier struct { + Alg Alg + Key interface{} + + Decrypt func([]byte) ([]byte, error) + + Extractors []TokenExtractor + Blocklist Blocklist + Validators []TokenValidator + + ErrorHandler func(ctx *context.Context, err error) +} + +// NewVerifier accepts the algorithm for the token's signature among with its (private) key +// and optionally some token validators for all verify middlewares that may initialized under this Verifier. +// +// See its Verify method. +func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...TokenValidator) *Verifier { + return &Verifier{ + Alg: signatureAlg, + Key: signatureKey, + Extractors: []TokenExtractor{FromHeader, FromQuery}, + ErrorHandler: func(ctx *context.Context, err error) { + ctx.StopWithError(401, context.PrivateError(err)) + }, + Validators: validators, + } +} + +// WithGCM enables AES-GCM payload encryption. +func (v *Verifier) WithGCM(key, additionalData []byte) *Verifier { + _, decrypt, err := jwt.GCM(key, additionalData) + if err != nil { + panic(err) // important error before serve, stop everything. + } + + v.Decrypt = decrypt + return v +} + +// WithDefaultBlocklist attaches an in-memory blocklist storage +// to invalidate tokens through server-side. +// To invalidate a token simply call the Context.Logout method. +func (v *Verifier) WithDefaultBlocklist() *Verifier { + v.Blocklist = jwt.NewBlocklist(30 * time.Minute) + return v +} + +func (v *Verifier) invalidate(ctx *context.Context) { + if verifiedToken := GetVerifiedToken(ctx); verifiedToken != nil { + v.Blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims.Expiry) + ctx.Values().Remove(claimsContextKey) + ctx.Values().Remove(verifiedTokenContextKey) + ctx.SetUser(nil) + ctx.SetLogoutFunc(nil) + } +} + +// RequestToken extracts the token from the request. +func (v *Verifier) RequestToken(ctx *context.Context) (token string) { + for _, extract := range v.Extractors { + if token = extract(ctx); token != "" { + break // ok we found it. + } + } + + return +} + +type ( + // ClaimsValidator is a special interface which, if the destination claims + // implements it then the verifier runs its Validate method before return. + ClaimsValidator interface { + Validate() error + } + + // ClaimsContextValidator same as ClaimsValidator but it accepts + // a request context which can be used for further checks before + // validating the incoming token's claims. + ClaimsContextValidator interface { + Validate(*context.Context) error + } +) + +// VerifyToken simply verifies the given "token" and validates its standard claims (such as expiration). +// Returns a structure which holds the token's information. See the Verify method instead. +func (v *Verifier) VerifyToken(token []byte, validators ...TokenValidator) (*VerifiedToken, error) { + return jwt.VerifyEncrypted(v.Alg, v.Key, v.Decrypt, token, validators...) +} + +// Verify is the most important piece of code inside the Verifier. +// It accepts the "claimsType" function which should return a pointer to a custom structure +// which the token's decode claims valuee will be binded and validated to. +// Returns a common Iris handler which can be used as a middleware to protect an API +// from unauthorized client requests. After this, the route handlers can access the claims +// through the jwt.Get package-level function. +// +// Example Code: +func (v *Verifier) Verify(claimsType func() interface{}, validators ...TokenValidator) context.Handler { + unmarshal := jwt.Unmarshal + if claimsType != nil { + c := claimsType() + if hasRequired(c) { + unmarshal = jwt.UnmarshalWithRequired + } + } + + if v.Blocklist != nil { + validators = append([]TokenValidator{v.Blocklist}, append(v.Validators, validators...)...) + } + + return func(ctx *context.Context) { + token := []byte(v.RequestToken(ctx)) + verifiedToken, err := v.VerifyToken(token, validators...) + if err != nil { + v.ErrorHandler(ctx, err) + return + } + + if claimsType != nil { + dest := claimsType() + if err = unmarshal(verifiedToken.Payload, dest); err != nil { + v.ErrorHandler(ctx, err) + return + } + + if validator, ok := dest.(ClaimsValidator); ok { + if err = validator.Validate(); err != nil { + v.ErrorHandler(ctx, err) + return + } + } else if contextValidator, ok := dest.(ClaimsContextValidator); ok { + if err = contextValidator.Validate(ctx); err != nil { + v.ErrorHandler(ctx, err) + return + } + } + + if u, ok := dest.(context.User); ok { + ctx.SetUser(u) + } + + ctx.Values().Set(claimsContextKey, dest) + } + + if v.Blocklist != nil { + ctx.SetLogoutFunc(v.invalidate) + } + + ctx.Values().Set(verifiedTokenContextKey, verifiedToken) + ctx.Next() + } +} + +func hasRequired(i interface{}) bool { + val := reflect.Indirect(reflect.ValueOf(i)) + typ := val.Type() + if typ.Kind() != reflect.Struct { + return false + } + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + if jwt.HasRequiredJSONTag(field) { + return true + } + } + + return false +}