diff --git a/HISTORY.md b/HISTORY.md index 95ad69f33..fcccde5f7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -29,7 +29,7 @@ The codebase for Dependency Injection, Internationalization and localization and ## Fixes and Improvements - A generic User interface, see the `Context.SetUser/User` methods in the New Context Methods section for more. In-short, the basicauth middleware's stored user can now be retrieved through `Context.User()` which provides more information than the native `ctx.Request().BasicAuth()` method one. Third-party authentication middleware creators can benefit of these two methods, plus the Logout below. -- A `Context.Logout` method is added, can be used to invalidate [basicauth](https://github.com/kataras/iris/blob/master/_examples/auth/basicauth/main.go) client credentials. +- A `Context.Logout` method is added, can be used to invalidate [basicauth](https://github.com/kataras/iris/blob/master/_examples/auth/basicauth/main.go) or [jwt](https://github.com/kataras/iris/blob/master/_examples/auth/jwt/overview/main.go) client credentials. - Add the ability to [share functions](https://github.com/kataras/iris/tree/master/_examples/routing/writing-a-middleware/share-funcs) between handlers chain and add an [example](https://github.com/kataras/iris/tree/master/_examples/routing/writing-a-middleware/share-services) on sharing Go structures (aka services). - Add the new `Party.UseOnce` method to the `*Route` @@ -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)). @@ -310,13 +310,14 @@ var dirOpts = iris.DirOptions{ ## New Context Methods +- `Context.ReadURL(ptr interface{}) error` shortcut of `ReadParams` and `ReadQuery`. Binds URL dynamic path parameters and URL query parameters to the given "ptr" pointer of a struct value. - `Context.SetUser(User)` and `Context.User() User` to store and retrieve an authenticated client. Read more [here](https://github.com/iris-contrib/middleware/issues/63). - `Context.SetLogoutFunc(fn interface{}, persistenceArgs ...interface{})` and `Logout(args ...interface{}) error` methods to allow different kind of auth middlewares to be able to set a "logout" a user/client feature with a single function, the route handler may not be aware of the implementation of the authentication used. - `Context.SetFunc(name string, fn interface{}, persistenceArgs ...interface{})` and `Context.CallFunc(name string, args ...interface{}) ([]reflect.Value, error)` to allow middlewares to share functions dynamically when the type of the function is not predictable, see the [example](https://github.com/kataras/iris/tree/master/_examples/routing/writing-a-middleware/share-funcs) for more. - `Context.TextYAML(interface{}) error` same as `Context.YAML` but with set the Content-Type to `text/yaml` instead (Google Chrome renders it as text). - `Context.IsDebug() bool` reports whether the application is running under debug/development mode. It is a shortcut of Application.Logger().Level >= golog.DebugLevel. -- `Context.IsRecovered() bool` reports whether the current request was recovered from the [recover middleware](https://github.com/kataras/iris/tree/master/middleware/recover). Also the `iris.IsErrPrivate` function and `iris.ErrPrivate` interface have been introduced. -- `Context.RecordBody()` same as the Application's `DisableBodyConsumptionOnUnmarshal` configuration field but registers per chain of handlers. It makes the request body readable more than once. +- `Context.IsRecovered() bool` reports whether the current request was recovered from the [recover middleware](https://github.com/kataras/iris/tree/master/middleware/recover). Also the `Context.GetErrPublic() (bool, error)`, `Context.SetErrPrivate(err error)` methods and `iris.ErrPrivate` interface have been introduced. +- `Context.RecordRequestBody(bool)` same as the Application's `DisableBodyConsumptionOnUnmarshal` configuration field but registers per chain of handlers. It makes the request body readable more than once. - `Context.IsRecordingBody() bool` reports whether the request body can be readen multiple times. - `Context.ReadHeaders(ptr interface{}) error` binds request headers to "ptr". [Example](https://github.com/kataras/iris/blob/master/_examples/request-body/read-headers/main.go). - `Context.ReadParams(ptr interface{}) error` binds dynamic path parameters to "ptr". [Example](https://github.com/kataras/iris/blob/master/_examples/request-body/read-params/main.go). @@ -490,6 +491,7 @@ Prior to this version the `iris.Context` was the only one dependency that has be | [net.IP](https://golang.org/pkg/net/#IP) | `net.ParseIP(ctx.RemoteAddr())` | | [mvc.Code](https://pkg.go.dev/github.com/kataras/iris/v12/mvc?tab=doc#Code) | `ctx.GetStatusCode() int` | | [mvc.Err](https://pkg.go.dev/github.com/kataras/iris/v12/mvc?tab=doc#Err) | `ctx.GetErr() error` | +| [iris/context.User](https://pkg.go.dev/github.com/kataras/iris/v12/context?tab=doc#User) | `ctx.User()` | | `string`, | | | `int, int8, int16, int32, int64`, | | | `uint, uint8, uint16, uint32, uint64`, | | diff --git a/NOTICE b/NOTICE index 5f0c9defb..042230a09 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/README.md b/_examples/README.md index ee830ce6e..f744c8d13 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -168,8 +168,9 @@ * [Bind Form](request-body/read-form/main.go) * [Checkboxes](request-body/read-form/checkboxes/main.go) * [Bind Query](request-body/read-query/main.go) - * [Bind Headers](request-body/read-headers/main.go) * [Bind Params](request-body/read-params/main.go) + * [Bind URL](request-body/read-url/main.go) + * [Bind Headers](request-body/read-headers/main.go) * [Bind Body](request-body/read-body/main.go) * [Bind Custom per type](request-body/read-custom-per-type/main.go) * [Bind Custom via Unmarshaler](request-body/read-custom-via-unmarshaler/main.go) @@ -197,8 +198,12 @@ * Authentication, Authorization & Bot Detection * [Basic Authentication](auth/basicauth/main.go) * [CORS](auth/cors) - * [JWT](auth/jwt/main.go) + * JSON Web Tokens + * [Basic](auth/jwt/basic/main.go) + * [Middleware](auth/jwt/midleware/main.go) + * [Blocklist](auth/jwt/blocklist/main.go) * [Refresh Token](auth/jwt/refresh-token/main.go) + * [Tutorial](auth/jwt/tutorial) * [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go) * [OAUth2](auth/goth/main.go) * [Manage Permissions](auth/permissions/main.go) @@ -218,6 +223,7 @@ * [Badger](sessions/database/badger/main.go) * [BoltDB](sessions/database/boltdb/main.go) * [Redis](sessions/database/redis/main.go) + * [View Data](sessions/viewdata) * Websocket * [Gorilla FileWatch (3rd-party)](websocket/gorilla-filewatch/main.go) * [Basic](websocket/basic) diff --git a/_examples/auth/basicauth/main.go b/_examples/auth/basicauth/main.go index 80ecae42c..0df1a5e55 100644 --- a/_examples/auth/basicauth/main.go +++ b/_examples/auth/basicauth/main.go @@ -56,7 +56,9 @@ func h(ctx iris.Context) { // makes sure for that, otherwise this handler will not be executed. // OR: user := ctx.User() - ctx.Writef("%s %s:%s", ctx.Path(), user.GetUsername(), user.GetPassword()) + username, _ := user.GetUsername() + password, _ := user.GetPassword + ctx.Writef("%s %s:%s", ctx.Path(), username, password) } func logout(ctx iris.Context) { diff --git a/_examples/auth/jwt/README.md b/_examples/auth/jwt/README.md deleted file mode 100644 index 1d2e0ae50..000000000 --- a/_examples/auth/jwt/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/basic/main.go b/_examples/auth/jwt/basic/main.go new file mode 100644 index 000000000..f37ce681f --- /dev/null +++ b/_examples/auth/jwt/basic/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "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() + + app.Get("/", generateToken) + app.Get("/protected", protected) + + app.Listen(":8080") +} + +type fooClaims struct { + Foo string `json:"foo"` +} + +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") + + // Decode the custom claims. + var claims fooClaims + verifiedToken.Claims(&claims) + + // 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/blocklist/main.go b/_examples/auth/jwt/blocklist/main.go new file mode 100644 index 000000000..8f3362c8f --- /dev/null +++ b/_examples/auth/jwt/blocklist/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/jwt" + "github.com/kataras/iris/v12/middleware/jwt/blocklist/redis" + + // Optionally to set token identifier. + "github.com/google/uuid" +) + +var ( + signatureSharedKey = []byte("sercrethatmaycontainch@r32length") + + signer = jwt.NewSigner(jwt.HS256, signatureSharedKey, 15*time.Minute) + verifier = jwt.NewVerifier(jwt.HS256, signatureSharedKey) +) + +type userClaims struct { + Username string `json:"username"` +} + +func main() { + app := iris.New() + + // IMPORTANT + // + // To use the in-memory blocklist just: + // verifier.WithDefaultBlocklist() + // To use a persistence blocklist, e.g. redis, + // start your redis-server and: + blocklist := redis.NewBlocklist() + // To configure single client or a cluster one: + // blocklist.ClientOptions.Addr = "127.0.0.1:6379" + // blocklist.ClusterOptions.Addrs = []string{...} + // To set a prefix for jwt ids: + // blocklist.Prefix = "myapp-" + // + // To manually connect and check its error before continue: + // err := blocklist.Connect() + // By default the verifier will try to connect, if failed then it will throw http error. + // + // And then register it: + verifier.Blocklist = blocklist + verifyMiddleware := verifier.Verify(func() interface{} { + return new(userClaims) + }) + + app.Get("/", authenticate) + + protectedAPI := app.Party("/protected", verifyMiddleware) + protectedAPI.Get("/", protected) + protectedAPI.Get("/logout", logout) + + // http://localhost:8080 + // http://localhost:8080/protected?token=$token + // http://localhost:8080/logout?token=$token + // http://localhost:8080/protected?token=$token (401) + app.Listen(":8080") +} + +func authenticate(ctx iris.Context) { + claims := userClaims{ + Username: "kataras", + } + + // Generate JWT ID. + random, err := uuid.NewRandom() + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + id := random.String() + + // Set the ID with the jwt.ID. + token, err := signer.Sign(claims, jwt.ID(id)) + + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.Write(token) +} + +func protected(ctx iris.Context) { + claims := jwt.Get(ctx).(*userClaims) + + // To the standard claims, e.g. the generated ID: + // jwt.GetVerifiedToken(ctx).StandardClaims.ID + + ctx.WriteString(claims.Username) +} + +func logout(ctx iris.Context) { + ctx.Logout() + + ctx.Redirect("/", iris.StatusTemporaryRedirect) +} diff --git a/_examples/auth/jwt/main.go b/_examples/auth/jwt/main.go deleted file mode 100644 index 2c31091fb..000000000 --- a/_examples/auth/jwt/main.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "time" - - "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/middleware/jwt" -) - -// UserClaims a custom claims structure. You can just use jwt.Claims too. -type UserClaims struct { - jwt.Claims - Username string -} - -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) - - app := iris.New() - app.Logger().SetLevel("debug") - - app.Get("/authenticate", func(ctx iris.Context) { - standardClaims := jwt.Claims{Issuer: "an-issuer", Audience: jwt.Audience{"an-audience"}} - // NOTE: if custom claims then the `j.Expiry(claims)` (or jwt.Expiry(duration, claims)) - // MUST be called in order to set the expiration time. - customClaims := UserClaims{ - Claims: j.Expiry(standardClaims), - Username: "kataras", - } - - j.WriteToken(ctx, customClaims) - }) - - userRouter := app.Party("/user") - { - // userRouter.Use(j.Verify) - // userRouter.Get("/", func(ctx iris.Context) { - // var claims UserClaims - // if err := jwt.ReadClaims(ctx, &claims); err != nil { - // // Validation-only errors, the rest are already - // // checked on `j.Verify` middleware. - // ctx.StopWithStatus(iris.StatusUnauthorized) - // return - // } - // - // ctx.Writef("Claims: %#+v\n", claims) - // }) - // - // OR: - userRouter.Get("/", func(ctx iris.Context) { - var claims UserClaims - if err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, claims.Expiry.Time()) - }) - } - - app.Listen(":8080") -} - -/* -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/middleware/main.go b/_examples/auth/jwt/middleware/main.go new file mode 100644 index 000000000..6af83e642 --- /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.WithEncryption(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.WithDecryption(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/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index 9e3be184c..1e6ca5879 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -1,139 +1,183 @@ package main import ( + "fmt" "time" "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/middleware/jwt" ) -// UserClaims a custom claims structure. You can just use jwt.Claims too. +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 { - jwt.Claims - Username string + 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"` } -// TokenPair holds the access token and refresh token response. -type TokenPair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` +// GetID implements the partial context user's ID interface. +// Note that if claims were a map then the claims value converted to UserClaims +// and no need to implement any method. +// +// This is useful when multiple auth methods are used (e.g. basic auth, jwt) +// but they all share a couple of methods. +func (u *UserClaims) GetID() string { + return u.ID } -func main() { - app := iris.New() +// GetUsername implements the partial context user's Username interface. +func (u *UserClaims) GetUsername() string { + return u.Username +} - // Access token, short-live. - accessJWT := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - // Refresh token, long-live. Important: Give different secret keys(!) - refreshJWT := jwt.HMAC(1*time.Hour, "other secret", "other16bytesecre") - // On refresh token, we extract it only from a request body - // of JSON, e.g. {"refresh_token": $token }. - // You can also do it manually in the handler level though. - refreshJWT.Extractors = []jwt.TokenExtractor{ - jwt.FromJSON("refresh_token"), +// 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") } - // Generate access and refresh tokens and send to the client. - app.Get("/authenticate", func(ctx iris.Context) { - tokenPair, err := generateTokenPair(accessJWT, refreshJWT) - if err != nil { - ctx.StopWithStatus(iris.StatusInternalServerError) - return - } + return nil +} - ctx.JSON(tokenPair) - }) - - app.Get("/refresh", func(ctx iris.Context) { - // Manual (if jwt.FromJSON missing): - // var payload = struct { - // RefreshToken string `json:"refresh_token"` - // }{} - // - // err := ctx.ReadJSON(&payload) - // if err != nil { - // ctx.StatusCode(iris.StatusBadRequest) - // return - // } - // - // j.VerifyTokenString(ctx, payload.RefreshToken, &claims) - - var claims jwt.Claims - if err := refreshJWT.VerifyToken(ctx, &claims); err != nil { - ctx.Application().Logger().Warnf("verify refresh token: %v", err) - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } +// For refresh token, we will just use the jwt.Claims +// structure which contains the standard JWT fields. - userID := claims.Subject - if userID == "" { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } +func main() { + app := iris.New() + app.OnErrorCode(iris.StatusUnauthorized, handleUnauthorized) + + app.Get("/authenticate", generateTokenPair) + app.Get("/refresh", refreshToken) + + protectedAPI := app.Party("/protected") + { + verifyMiddleware := verifier.Verify(func() interface{} { + return new(UserClaims) + }) + + protectedAPI.Use(verifyMiddleware) + + protectedAPI.Get("/", func(ctx iris.Context) { + // Access the claims through: jwt.Get: + // claims := jwt.Get(ctx).(*UserClaims) + // ctx.Writef("Username: %s\n", claims.Username) + // + // OR through context's user (if at least one method was implement by our UserClaims): + user := ctx.User() + id, _ := user.GetID() + username, _ := user.GetUsername() + ctx.Writef("ID: %s\nUsername: %s\n", id, username) + }) + } - // Simulate a database call against our jwt subject. - if userID != "53afcf05-38a3-43c3-82af-8bbbe0e4a149" { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } + // http://localhost:8080/protected (401) + // 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?refresh_token={refresh_token} + // OR http://localhost:8080/refresh (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // http://localhost:8080/refresh?refresh_token={access_token} (401) + app.Listen(":8080") +} - // All OK, re-generate the new pair and send to client. - tokenPair, err := generateTokenPair(accessJWT, refreshJWT) - if err != nil { - ctx.StopWithStatus(iris.StatusInternalServerError) - return - } +func generateTokenPair(ctx iris.Context) { + // Simulate a user... + userID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" - ctx.JSON(tokenPair) - }) + // 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. + refreshClaims := jwt.Claims{Subject: userID} - app.Get("/", func(ctx iris.Context) { - var claims UserClaims - if err := accessJWT.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } + accessClaims := UserClaims{ + ID: userID, + Username: "kataras", + } - ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, claims.Expiry.Time()) - }) + // Generates a Token Pair, long-live for refresh tokens, e.g. 1 hour. + // 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().Errorf("token pair: %v", err) + ctx.StopWithStatus(iris.StatusInternalServerError) + return + } - // http://localhost:8080 (401) - // http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token}) - // http://localhost:8080?token={access_token} (200) - // http://localhost:8080?token={refresh_token} (401) - // http://localhost:8080/refresh (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) - app.Listen(":8080") + // Send the generated token pair to the client. + // The tokenPair looks like: {"access_token": $token, "refresh_token": $token} + ctx.JSON(tokenPair) } -func generateTokenPair(accessJWT, refreshJWT *jwt.JWT) (TokenPair, error) { - standardClaims := jwt.Claims{Issuer: "an-issuer", Audience: jwt.Audience{"an-audience"}} +// 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 + // to make sure that this refresh token is a pair generated by this user. + // * 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 + } - customClaims := UserClaims{ - Claims: accessJWT.Expiry(standardClaims), - Username: "kataras", + refreshToken = tokenPair.RefreshToken } - accessToken, err := accessJWT.Token(customClaims) + // Verify the refresh token, which its subject MUST match the "currentUserID". + _, err := verifier.VerifyToken(refreshToken, jwt.Expected{Subject: currentUserID}) if err != nil { - return TokenPair{}, err + ctx.Application().Logger().Errorf("verify refresh token: %v", err) + ctx.StatusCode(iris.StatusUnauthorized) + return } - // At refresh tokens you don't need any custom claims. - refreshClaims := refreshJWT.Expiry(jwt.Claims{ - ID: "refresh_kataras", - // For example, the User ID, - // this is necessary to check against the database - // if the user still exist or has credentials to access our page. - Subject: "53afcf05-38a3-43c3-82af-8bbbe0e4a149", - }) + /* Custom validation checks can be performed after Verify calls too: + currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" + userID := verifiedToken.StandardClaims.Subject + if userID != currentUserID { + ctx.StopWithStatus(iris.StatusUnauthorized) + return + } + */ - refreshToken, err := refreshJWT.Token(refreshClaims) - if err != nil { - return TokenPair{}, err + // 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) } - return TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - }, nil + 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 000000000..411e3a89e --- /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 000000000..99a96176a --- /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/_examples/auth/jwt/rsa_password_protected.key b/_examples/auth/jwt/rsa_password_protected.key deleted file mode 100644 index e93fff776..000000000 --- a/_examples/auth/jwt/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/tutorial/README.md b/_examples/auth/jwt/tutorial/README.md new file mode 100644 index 000000000..5a52c56b9 --- /dev/null +++ b/_examples/auth/jwt/tutorial/README.md @@ -0,0 +1,62 @@ +# Iris JWT Tutorial + +This example show how to use JWT with domain-driven design pattern with Iris. There is also a simple Go client which describes how you can use Go to authorize a user and use the server's API. + +## Run the server + +```sh +$ go run main.go +``` + +## Authenticate, get the token + +```sh +$ curl --location --request POST 'http://localhost:8080/signin' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--data-urlencode 'username=admin' \ +--data-urlencode 'password=admin' + +> $token +``` + +## Get all TODOs for this User + +```sh +$ curl --location --request GET 'http://localhost:8080/todos' \ +--header 'Authorization: Bearer $token' + +> $todos +``` + +## Get a specific User's TODO + +```sh +$ curl --location --request GET 'http://localhost:8080/todos/$id' \ +--header 'Authorization: Bearer $token' + +> $todo +``` + +## Get all TODOs for all Users (admin role) + +```sh +$ curl --location --request GET 'http://localhost:8080/admin/todos' \ +--header 'Authorization: Bearer $token' + +> $todos +``` + +## Create a new TODO + +```sh +$ curl --location --request POST 'http://localhost:8080/todos' \ +--header 'Authorization: Bearer $token' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "title": "test titlte", + "body": "test body" +}' + +> Status Created +> $todo +``` diff --git a/_examples/auth/jwt/tutorial/api/auth.go b/_examples/auth/jwt/tutorial/api/auth.go new file mode 100644 index 000000000..2e540e099 --- /dev/null +++ b/_examples/auth/jwt/tutorial/api/auth.go @@ -0,0 +1,140 @@ +package api + +import ( + "fmt" + "os" + "time" + + "myapp/domain/model" + "myapp/domain/repository" + "myapp/util" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/jwt" +) + +const defaultSecretKey = "sercrethatmaycontainch@r$32chars" + +func getSecretKey() string { + secret := os.Getenv(util.AppName + "_SECRET") + if secret == "" { + return defaultSecretKey + } + + return secret +} + +// UserClaims represents the user token claims. +type UserClaims struct { + UserID string `json:"user_id"` + Roles []model.Role `json:"roles"` +} + +// Validate implements the custom struct claims validator, +// this is totally optionally and maybe unnecessary but good to know how. +func (u *UserClaims) Validate() error { + if u.UserID == "" { + return fmt.Errorf("%w: %s", jwt.ErrMissingKey, "user_id") + } + + return nil +} + +// Verify allows only authorized clients. +func Verify() iris.Handler { + secret := getSecretKey() + + verifier := jwt.NewVerifier(jwt.HS256, []byte(secret), jwt.Expected{Issuer: util.AppName}) + verifier.Extractors = []jwt.TokenExtractor{jwt.FromHeader} // extract token only from Authorization: Bearer $token + return verifier.Verify(func() interface{} { + return new(UserClaims) + }) +} + +// AllowAdmin allows only authorized clients with "admin" access role. +// Should be registered after Verify. +func AllowAdmin(ctx iris.Context) { + if !IsAdmin(ctx) { + ctx.StopWithText(iris.StatusForbidden, "admin access required") + return + } + + ctx.Next() +} + +// SignIn accepts the user form data and returns a token to authorize a client. +func SignIn(repo repository.UserRepository) iris.Handler { + secret := getSecretKey() + signer := jwt.NewSigner(jwt.HS256, []byte(secret), 15*time.Minute) + + return func(ctx iris.Context) { + /* + type LoginForm struct { + Username string `form:"username"` + Password string `form:"password"` + } + and ctx.ReadForm OR use the ctx.FormValue(s) method. + */ + + var ( + username = ctx.FormValue("username") + password = ctx.FormValue("password") + ) + + user, ok := repo.GetByUsernameAndPassword(username, password) + if !ok { + ctx.StopWithText(iris.StatusBadRequest, "wrong username or password") + return + } + + claims := UserClaims{ + UserID: user.ID, + Roles: user.Roles, + } + + // Optionally, generate a JWT ID. + jti, err := util.GenerateUUID() + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + token, err := signer.Sign(claims, jwt.Claims{ + ID: jti, + Issuer: util.AppName, + }) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.Write(token) + } +} + +// SignOut invalidates a user from server-side using the jwt Blocklist. +func SignOut(ctx iris.Context) { + ctx.Logout() // this is automatically binded to a function which invalidates the current request token by the JWT Verifier above. +} + +// GetClaims returns the current authorized client claims. +func GetClaims(ctx iris.Context) *UserClaims { + claims := jwt.Get(ctx).(*UserClaims) + return claims +} + +// GetUserID returns the current authorized client's user id extracted from claims. +func GetUserID(ctx iris.Context) string { + return GetClaims(ctx).UserID +} + +// IsAdmin reports whether the current client has admin access. +func IsAdmin(ctx iris.Context) bool { + for _, role := range GetClaims(ctx).Roles { + if role == model.Admin { + return true + } + } + + return false +} diff --git a/_examples/auth/jwt/tutorial/api/todo.go b/_examples/auth/jwt/tutorial/api/todo.go new file mode 100644 index 000000000..317494e85 --- /dev/null +++ b/_examples/auth/jwt/tutorial/api/todo.go @@ -0,0 +1,119 @@ +package api + +import ( + "errors" + "myapp/domain/repository" + + "github.com/kataras/iris/v12" +) + +// TodoRequest represents a Todo HTTP request. +type TodoRequest struct { + Title string `json:"title" form:"title" url:"title"` + Body string `json:"body" form:"body" url:"body"` +} + +// CreateTodo handles the creation of a Todo entry. +func CreateTodo(repo repository.TodoRepository) iris.Handler { + return func(ctx iris.Context) { + var req TodoRequest + err := ctx.ReadBody(&req) // will bind the "req" to a JSON, form or url query request data. + if err != nil { + ctx.StopWithError(iris.StatusBadRequest, err) + return + } + + userID := GetUserID(ctx) + todo, err := repo.Create(userID, req.Title, req.Body) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.StatusCode(iris.StatusCreated) + ctx.JSON(todo) + } +} + +// GetTodo lists all users todos. +// Parameter: {id}. +func GetTodo(repo repository.TodoRepository) iris.Handler { + return func(ctx iris.Context) { + id := ctx.Params().Get("id") + userID := GetUserID(ctx) + + todo, err := repo.GetByID(id) + if err != nil { + code := iris.StatusInternalServerError + if errors.Is(err, repository.ErrNotFound) { + code = iris.StatusNotFound + } + + ctx.StopWithError(code, err) + return + } + + if !IsAdmin(ctx) { // admin can access any user's todos. + if todo.UserID != userID { + ctx.StopWithStatus(iris.StatusForbidden) + return + } + } + + ctx.JSON(todo) + } +} + +// ListTodos lists todos of the current user. +func ListTodos(repo repository.TodoRepository) iris.Handler { + return func(ctx iris.Context) { + userID := GetUserID(ctx) + todos, err := repo.GetAllByUser(userID) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + // if len(todos) == 0 { + // ctx.StopWithError(iris.StatusNotFound, fmt.Errorf("no entries found")) + // return + // } + // Or let the client decide what to do on empty list. + ctx.JSON(todos) + } +} + +// ListAllTodos lists all users todos. +// Access: admin. +// Middleware: AllowAdmin. +func ListAllTodos(repo repository.TodoRepository) iris.Handler { + return func(ctx iris.Context) { + todos, err := repo.GetAll() + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.JSON(todos) + } +} + +/* Leave as exercise: use filtering instead... + +// ListTodosByUser lists all todos by a specific user. +// Access: admin. +// Middleware: AllowAdmin. +// Parameter: {id}. +func ListTodosByUser(repo repository.TodoRepository) iris.Handler { + return func(ctx iris.Context) { + userID := ctx.Params().Get("id") + todos, err := repo.GetAllByUser(userID) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.JSON(todos) + } +} +*/ diff --git a/_examples/auth/jwt/tutorial/domain/model/role.go b/_examples/auth/jwt/tutorial/domain/model/role.go new file mode 100644 index 000000000..00fd1c256 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/role.go @@ -0,0 +1,9 @@ +package model + +// Role represents a role. +type Role string + +const ( + // Admin represents the Admin access role. + Admin Role = "admin" +) diff --git a/_examples/auth/jwt/tutorial/domain/model/todo.go b/_examples/auth/jwt/tutorial/domain/model/todo.go new file mode 100644 index 000000000..d0a2ea273 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/todo.go @@ -0,0 +1,10 @@ +package model + +// Todo represents the Todo model. +type Todo struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt int64 `json:"created_at"` // unix seconds. +} diff --git a/_examples/auth/jwt/tutorial/domain/model/user.go b/_examples/auth/jwt/tutorial/domain/model/user.go new file mode 100644 index 000000000..9fa8bcdfd --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/model/user.go @@ -0,0 +1,9 @@ +package model + +// User represents our User model. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + HashedPassword []byte `json:"-"` + Roles []Role `json:"roles"` +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/samples.go b/_examples/auth/jwt/tutorial/domain/repository/samples.go new file mode 100644 index 000000000..b7d267ba0 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/samples.go @@ -0,0 +1,45 @@ +package repository + +import ( + "fmt" + + "myapp/domain/model" +) + +// GenerateSamples generates data samples. +func GenerateSamples(userRepo UserRepository, todoRepo TodoRepository) error { + // Create users. + for _, username := range []string{"vasiliki", "george", "kwstas"} { + // My grandmother. + // My young brother. + // My youngest brother. + password := fmt.Sprintf("%s_pass", username) + if _, err := userRepo.Create(username, password); err != nil { + return err + } + } + + // Create a user with admin role. + if _, err := userRepo.Create("admin", "admin", model.Admin); err != nil { + return err + } + + // Create two todos per user. + users, err := userRepo.GetAll() + if err != nil { + return err + } + + for i, u := range users { + for j := 0; j < 2; j++ { + title := fmt.Sprintf("%s todo %d:%d title", u.Username, i, j) + body := fmt.Sprintf("%s todo %d:%d body", u.Username, i, j) + _, err := todoRepo.Create(u.ID, title, body) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go b/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go new file mode 100644 index 000000000..a3c8429f3 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/todo_repository.go @@ -0,0 +1,94 @@ +package repository + +import ( + "errors" + "sync" + + "myapp/domain/model" + "myapp/util" +) + +// ErrNotFound indicates that an entry was not found. +// Usage: errors.Is(err, ErrNotFound) +var ErrNotFound = errors.New("not found") + +// TodoRepository is responsible for Todo CRUD operations, +// however, for the sake of the example we only implement the Create and Read ones. +type TodoRepository interface { + Create(userID, title, body string) (model.Todo, error) + GetByID(id string) (model.Todo, error) + GetAll() ([]model.Todo, error) + GetAllByUser(userID string) ([]model.Todo, error) +} + +var ( + _ TodoRepository = (*memoryTodoRepository)(nil) +) + +type memoryTodoRepository struct { + todos []model.Todo // map[string]model.Todo + mu sync.RWMutex +} + +// NewMemoryTodoRepository returns the default in-memory todo repository. +func NewMemoryTodoRepository() TodoRepository { + r := new(memoryTodoRepository) + return r +} + +func (r *memoryTodoRepository) Create(userID, title, body string) (model.Todo, error) { + id, err := util.GenerateUUID() + if err != nil { + return model.Todo{}, err + } + + todo := model.Todo{ + ID: id, + UserID: userID, + Title: title, + Body: body, + CreatedAt: util.Now().Unix(), + } + + r.mu.Lock() + r.todos = append(r.todos, todo) + r.mu.Unlock() + + return todo, nil +} + +func (r *memoryTodoRepository) GetByID(id string) (model.Todo, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, todo := range r.todos { + if todo.ID == id { + return todo, nil + } + } + + return model.Todo{}, ErrNotFound +} + +func (r *memoryTodoRepository) GetAll() ([]model.Todo, error) { + r.mu.RLock() + tmp := make([]model.Todo, len(r.todos)) + copy(tmp, r.todos) + r.mu.RUnlock() + return tmp, nil +} + +func (r *memoryTodoRepository) GetAllByUser(userID string) ([]model.Todo, error) { + // initialize a slice, so we don't have "null" at empty response. + todos := make([]model.Todo, 0) + + r.mu.RLock() + for _, todo := range r.todos { + if todo.UserID == userID { + todos = append(todos, todo) + } + } + r.mu.RUnlock() + + return todos, nil +} diff --git a/_examples/auth/jwt/tutorial/domain/repository/user_repository.go b/_examples/auth/jwt/tutorial/domain/repository/user_repository.go new file mode 100644 index 000000000..b6e0bd764 --- /dev/null +++ b/_examples/auth/jwt/tutorial/domain/repository/user_repository.go @@ -0,0 +1,82 @@ +package repository + +import ( + "sync" + + "myapp/domain/model" + "myapp/util" +) + +// UserRepository is responsible for User CRUD operations, +// however, for the sake of the example we only implement the Read one. +type UserRepository interface { + Create(username, password string, roles ...model.Role) (model.User, error) + // GetByUsernameAndPassword should return a User based on the given input. + GetByUsernameAndPassword(username, password string) (model.User, bool) + GetAll() ([]model.User, error) +} + +var ( + _ UserRepository = (*memoryUserRepository)(nil) +) + +type memoryUserRepository struct { + // Users represents a user database. + // For the sake of the tutorial we use a simple slice of users. + users []model.User + mu sync.RWMutex +} + +// NewMemoryUserRepository returns the default in-memory user repository. +func NewMemoryUserRepository() UserRepository { + r := new(memoryUserRepository) + return r +} + +func (r *memoryUserRepository) Create(username, password string, roles ...model.Role) (model.User, error) { + id, err := util.GenerateUUID() + if err != nil { + return model.User{}, err + } + + hashedPassword, err := util.GeneratePassword(password) + if err != nil { + return model.User{}, err + } + + user := model.User{ + ID: id, + Username: username, + HashedPassword: hashedPassword, + Roles: roles, + } + + r.mu.Lock() + r.users = append(r.users, user) + r.mu.Unlock() + + return user, nil +} + +// GetByUsernameAndPassword returns a user from the storage based on the given "username" and "password". +func (r *memoryUserRepository) GetByUsernameAndPassword(username, password string) (model.User, bool) { + for _, u := range r.users { // our example uses a static slice. + if u.Username == username { + // we compare the user input and the stored hashed password. + ok := util.ValidatePassword(password, u.HashedPassword) + if ok { + return u, true + } + } + } + + return model.User{}, false +} + +func (r *memoryUserRepository) GetAll() ([]model.User, error) { + r.mu.RLock() + tmp := make([]model.User, len(r.users)) + copy(tmp, r.users) + r.mu.RUnlock() + return tmp, nil +} diff --git a/_examples/auth/jwt/tutorial/go-client/README.md b/_examples/auth/jwt/tutorial/go-client/README.md new file mode 100644 index 000000000..f7a96a9cf --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/README.md @@ -0,0 +1,12 @@ +# Go Client + +```sh +$ go run . +``` + +```sh +2020/11/04 21:08:40 Access Token: +"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYTAwYzI3ZDEtYjVhYS00NjU0LWFmMTYtYjExNzNkZTY1NjI5Iiwicm9sZXMiOlsiYWRtaW4iXSwiaWF0IjoxNjA0NTE2OTIwLCJleHAiOjE2MDQ1MTc4MjAsImp0aSI6IjYzNmVmMDc0LTE2MzktNGJhZi1hNGNiLTQ4ZDM4NGMxMzliYSIsImlzcyI6Im15YXBwIn0.T9B0zG0AHShO5JfQgrMQBlToH33KHgp8nLMPFpN6QmM" +2020/11/04 21:08:40 Todo Created: +model.Todo{ID:"cfa38d7a-c556-4301-ae1f-fb90f705071c", UserID:"a00c27d1-b5aa-4654-af16-b1173de65629", Title:"test todo title", Body:"test todo body contents", CreatedAt:1604516920} +``` diff --git a/_examples/auth/jwt/tutorial/go-client/client.go b/_examples/auth/jwt/tutorial/go-client/client.go new file mode 100644 index 000000000..e96b769f2 --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/client.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Client is the default http client instance used by the following methods. +var Client = http.DefaultClient + +// RequestOption is a function which can be used to modify +// a request instance before Do. +type RequestOption func(*http.Request) error + +// WithAccessToken sets the given "token" to the authorization request header. +func WithAccessToken(token []byte) RequestOption { + bearer := "Bearer " + string(token) + return func(req *http.Request) error { + req.Header.Add("Authorization", bearer) + return nil + } +} + +// WithContentType sets the content-type request header. +func WithContentType(cType string) RequestOption { + return func(req *http.Request) error { + req.Header.Set("Content-Type", cType) + return nil + } +} + +// WithContentLength sets the content-length request header. +func WithContentLength(length int) RequestOption { + return func(req *http.Request) error { + req.Header.Set("Content-Length", strconv.Itoa(length)) + return nil + } +} + +// Do fires a request to the server. +func Do(method, url string, body io.Reader, opts ...RequestOption) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + + for _, opt := range opts { + if err = opt(req); err != nil { + return nil, err + } + } + + return Client.Do(req) +} + +// JSON fires a request with "v" as client json data. +func JSON(method, url string, v interface{}, opts ...RequestOption) (*http.Response, error) { + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(v) + if err != nil { + return nil, err + } + + opts = append(opts, WithContentType("application/json; charset=utf-8")) + return Do(method, url, buf, opts...) +} + +// Form fires a request with "formData" as client form data. +func Form(method, url string, formData url.Values, opts ...RequestOption) (*http.Response, error) { + encoded := formData.Encode() + body := strings.NewReader(encoded) + + opts = append([]RequestOption{ + WithContentType("application/x-www-form-urlencoded"), + WithContentLength(len(encoded)), + }, opts...) + + return Do(method, url, body, opts...) +} + +// BindResponse binds a response body to the "dest" pointer and closes the body. +func BindResponse(resp *http.Response, dest interface{}) error { + contentType := resp.Header.Get("Content-Type") + if idx := strings.IndexRune(contentType, ';'); idx > 0 { + contentType = contentType[0:idx] + } + + switch contentType { + case "application/json": + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(dest) + default: + return fmt.Errorf("unsupported content type: %s", contentType) + } +} + +// RawResponse simply returns the raw response body. +func RawResponse(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() + + return ioutil.ReadAll(resp.Body) +} diff --git a/_examples/auth/jwt/tutorial/go-client/main.go b/_examples/auth/jwt/tutorial/go-client/main.go new file mode 100644 index 000000000..d22688e13 --- /dev/null +++ b/_examples/auth/jwt/tutorial/go-client/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/url" + + "myapp/api" + "myapp/domain/model" +) + +const base = "http://localhost:8080" + +func main() { + accessToken, err := authenticate("admin", "admin") + if err != nil { + log.Fatal(err) + } + + log.Printf("Access Token:\n%q", accessToken) + + todo, err := createTodo(accessToken, "test todo title", "test todo body contents") + if err != nil { + log.Fatal(err) + } + + log.Printf("Todo Created:\n%#+v", todo) +} + +func authenticate(username, password string) ([]byte, error) { + endpoint := base + "/signin" + + data := make(url.Values) + data.Set("username", username) + data.Set("password", password) + + resp, err := Form(http.MethodPost, endpoint, data) + if err != nil { + return nil, err + } + + accessToken, err := RawResponse(resp) + return accessToken, err +} + +func createTodo(accessToken []byte, title, body string) (model.Todo, error) { + var todo model.Todo + + endpoint := base + "/todos" + + req := api.TodoRequest{ + Title: title, + Body: body, + } + + resp, err := JSON(http.MethodPost, endpoint, req, WithAccessToken(accessToken)) + if err != nil { + return todo, err + } + + if resp.StatusCode != http.StatusCreated { + rawData, _ := RawResponse(resp) + return todo, fmt.Errorf("failed to create a todo: %s", string(rawData)) + } + + err = BindResponse(resp, &todo) + return todo, err +} diff --git a/_examples/auth/jwt/tutorial/go.mod b/_examples/auth/jwt/tutorial/go.mod new file mode 100644 index 000000000..3bcd36bd3 --- /dev/null +++ b/_examples/auth/jwt/tutorial/go.mod @@ -0,0 +1,11 @@ +module myapp + +go 1.15 + +require ( + github.com/google/uuid v1.1.2 + github.com/kataras/iris/v12 v12.2.0-alpha.0.20201031040657-23d4c411cadb + golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 +) + +replace github.com/kataras/iris/v12 => ../../../../ diff --git a/_examples/auth/jwt/tutorial/main.go b/_examples/auth/jwt/tutorial/main.go new file mode 100644 index 000000000..8be430811 --- /dev/null +++ b/_examples/auth/jwt/tutorial/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "myapp/api" + "myapp/domain/repository" + + "github.com/kataras/iris/v12" +) + +var ( + userRepository = repository.NewMemoryUserRepository() + todoRepository = repository.NewMemoryTodoRepository() +) + +func main() { + if err := repository.GenerateSamples(userRepository, todoRepository); err != nil { + panic(err) + } + + app := iris.New() + + app.Post("/signin", api.SignIn(userRepository)) + + verify := api.Verify() + + todosAPI := app.Party("/todos", verify) + todosAPI.Post("/", api.CreateTodo(todoRepository)) + todosAPI.Get("/", api.ListTodos(todoRepository)) + todosAPI.Get("/{id}", api.GetTodo(todoRepository)) + + adminAPI := app.Party("/admin", verify, api.AllowAdmin) + adminAPI.Get("/todos", api.ListAllTodos(todoRepository)) + + // POST http://localhost:8080/signin (Form: username, password) + // GET http://localhost:8080/todos + // GET http://localhost:8080/todos/{id} + // POST http://localhost:8080/todos (JSON, Form or URL: title, body) + // GET http://localhost:8080/admin/todos + app.Listen(":8080") +} diff --git a/_examples/auth/jwt/tutorial/util/app.go b/_examples/auth/jwt/tutorial/util/app.go new file mode 100644 index 000000000..7a991f01c --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/app.go @@ -0,0 +1,7 @@ +package util + +// Constants for the application. +const ( + Version = "0.0.1" + AppName = "myapp" +) diff --git a/_examples/auth/jwt/tutorial/util/clock.go b/_examples/auth/jwt/tutorial/util/clock.go new file mode 100644 index 000000000..34e35eb22 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/clock.go @@ -0,0 +1,7 @@ +package util + +import "time" + +// Now is the default current time for the whole application. +// Can be modified for testing or custom timezone. +var Now = time.Now diff --git a/_examples/auth/jwt/tutorial/util/password.go b/_examples/auth/jwt/tutorial/util/password.go new file mode 100644 index 000000000..c18d0a916 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/password.go @@ -0,0 +1,25 @@ +package util + +import "golang.org/x/crypto/bcrypt" + +// MustGeneratePassword same as GeneratePassword but panics on errors. +func MustGeneratePassword(userPassword string) []byte { + hashed, err := GeneratePassword(userPassword) + if err != nil { + panic(err) + } + + return hashed +} + +// GeneratePassword will generate a hashed password for us based on the +// user's input. +func GeneratePassword(userPassword string) ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost) +} + +// ValidatePassword will check if passwords are matched. +func ValidatePassword(userPassword string, hashed []byte) bool { + err := bcrypt.CompareHashAndPassword(hashed, []byte(userPassword)) + return err == nil +} diff --git a/_examples/auth/jwt/tutorial/util/uuid.go b/_examples/auth/jwt/tutorial/util/uuid.go new file mode 100644 index 000000000..0269d62d7 --- /dev/null +++ b/_examples/auth/jwt/tutorial/util/uuid.go @@ -0,0 +1,23 @@ +package util + +import "github.com/google/uuid" + +// MustGenerateUUID returns a new v4 UUID or panics. +func MustGenerateUUID() string { + id, err := GenerateUUID() + if err != nil { + panic(err) + } + + return id +} + +// GenerateUUID returns a new v4 UUID. +func GenerateUUID() (string, error) { + id, err := uuid.NewRandom() + if err != nil { + return "", err + } + + return id.String(), nil +} diff --git a/_examples/dependency-injection/jwt/contrib/go.mod b/_examples/dependency-injection/jwt/contrib/go.mod index 990ef4e0b..97b9162e9 100644 --- a/_examples/dependency-injection/jwt/contrib/go.mod +++ b/_examples/dependency-injection/jwt/contrib/go.mod @@ -2,4 +2,6 @@ module github.com/kataras/iris/_examples/dependency-injection/jwt/contrib go 1.15 -require github.com/iris-contrib/middleware/jwt v0.0.0-20200810001613-32cf668f999f +require ( + github.com/iris-contrib/middleware/jwt v0.0.0-20201017024110-39b50ffeb885 +) diff --git a/_examples/dependency-injection/jwt/main.go b/_examples/dependency-injection/jwt/main.go index c705fac9a..ff328eb9a 100644 --- a/_examples/dependency-injection/jwt/main.go +++ b/_examples/dependency-injection/jwt/main.go @@ -11,37 +11,57 @@ func main() { app := iris.New() app.ConfigureContainer(register) + // http://localhost:8080/authenticate + // http://localhost:8080/restricted (Header: Authorization = Bearer $token) app.Listen(":8080") } -func register(api *iris.APIContainer) { - j := jwt.HMAC(15*time.Minute, "secret", "secretforencrypt") +var secret = []byte("secret") +func register(api *iris.APIContainer) { api.RegisterDependency(func(ctx iris.Context) (claims userClaims) { - if err := j.VerifyToken(ctx, &claims); err != nil { + /* Using the middleware: + if ctx.Proceed(verify) { + // ^ the "verify" middleware will stop the execution if it's failed to verify the request. + // Map the input parameter of "restricted" function with the claims. + return jwt.Get(ctx).(*userClaims) + }*/ + token := jwt.FromHeader(ctx) + if token == "" { + ctx.StopWithError(iris.StatusUnauthorized, jwt.ErrMissing) + return + } + + verifiedToken, err := jwt.Verify(jwt.HS256, secret, []byte(token)) + if err != nil { ctx.StopWithError(iris.StatusUnauthorized, err) return } + verifiedToken.Claims(&claims) return }) - api.Get("/authenticate", writeToken(j)) + api.Get("/authenticate", writeToken) api.Get("/restricted", restrictedPage) } type userClaims struct { - jwt.Claims - Username string + Username string `json:"username"` } -func writeToken(j *jwt.JWT) iris.Handler { - return func(ctx iris.Context) { - j.WriteToken(ctx, userClaims{ - Claims: j.Expiry(jwt.Claims{Issuer: "an-issuer"}), - Username: "kataras", - }) +func writeToken(ctx iris.Context) { + claims := userClaims{ + Username: "kataras", } + + token, err := jwt.Sign(jwt.HS256, secret, claims, jwt.MaxAge(1*time.Minute)) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.Write(token) } func restrictedPage(claims userClaims) string { diff --git a/_examples/logging/request-logger/accesslog-simple/main.go b/_examples/logging/request-logger/accesslog-simple/main.go index c966e2be6..77880c8bf 100644 --- a/_examples/logging/request-logger/accesslog-simple/main.go +++ b/_examples/logging/request-logger/accesslog-simple/main.go @@ -29,7 +29,10 @@ func makeAccessLog() *accesslog.AccessLog { ac.PanicLog = accesslog.LogHandler // Set Custom Formatter: - ac.SetFormatter(&accesslog.JSON{}) + ac.SetFormatter(&accesslog.JSON{ + Indent: " ", + HumanTime: true, + }) // ac.SetFormatter(&accesslog.CSV{}) // ac.SetFormatter(&accesslog.Template{Text: "{{.Code}}"}) diff --git a/_examples/request-body/read-url/main.go b/_examples/request-body/read-url/main.go new file mode 100644 index 000000000..36521614c --- /dev/null +++ b/_examples/request-body/read-url/main.go @@ -0,0 +1,38 @@ +// package main contains an example on how to use the ReadURL, +// same way you can do the ReadQuery, ReadParams, ReadJSON, ReadProtobuf and e.t.c. +package main + +import ( + "github.com/kataras/iris/v12" +) + +type myURL struct { + Name string `url:"name"` // or `param:"name"` + Age int `url:"age"` // >> >> + Tail []string `url:"tail"` // >> >> +} + +func main() { + app := newApp() + + // http://localhost:8080/iris/web/framework?name=kataras&age=27 + // myURL: main.myURL{Name:"kataras", Age:27, Tail:[]string{"iris", "web", "framework"}} + app.Listen(":8080") +} + +func newApp() *iris.Application { + app := iris.New() + + app.Get("/{tail:path}", func(ctx iris.Context) { + var u myURL + // ReadURL is a shortcut of ReadParams + ReadQuery. + if err := ctx.ReadURL(&u); err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.Writef("myURL: %#v", u) + }) + + return app +} diff --git a/_examples/request-body/read-url/main_test.go b/_examples/request-body/read-url/main_test.go new file mode 100644 index 000000000..211069004 --- /dev/null +++ b/_examples/request-body/read-url/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + "github.com/kataras/iris/v12/httptest" +) + +func TestReadURL(t *testing.T) { + app := newApp() + + e := httptest.New(t, app) + + expectedBody := `myURL: main.myURL{Name:"kataras", Age:27, Tail:[]string{"iris", "web", "framework"}}` + e.GET("/iris/web/framework").WithQuery("name", "kataras").WithQuery("age", 27).Expect().Status(httptest.StatusOK).Body().Equal(expectedBody) +} diff --git a/_examples/sessions/overview/example/example.go b/_examples/sessions/overview/example/example.go index ed4fb5e49..e47624d72 100644 --- a/_examples/sessions/overview/example/example.go +++ b/_examples/sessions/overview/example/example.go @@ -38,9 +38,9 @@ func NewApp(sess *sessions.Sessions) *iris.Application { session := sessions.Get(ctx) isNew := session.IsNew() - session.Set("name", "iris") + session.Set("username", "iris") - ctx.Writef("All ok session set to: %s [isNew=%t]", session.GetString("name"), isNew) + ctx.Writef("All ok session set to: %s [isNew=%t]", session.GetString("username"), isNew) }) app.Get("/get", func(ctx iris.Context) { @@ -48,9 +48,9 @@ func NewApp(sess *sessions.Sessions) *iris.Application { // get a specific value, as string, // if not found then it returns just an empty string. - name := session.GetString("name") + name := session.GetString("username") - ctx.Writef("The name on the /set was: %s", name) + ctx.Writef("The username on the /set was: %s", name) }) app.Get("/set-struct", func(ctx iris.Context) { diff --git a/_examples/sessions/viewdata/main.go b/_examples/sessions/viewdata/main.go new file mode 100644 index 000000000..44aae9c1e --- /dev/null +++ b/_examples/sessions/viewdata/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/sessions" +) + +func main() { + app := iris.New() + app.RegisterView(iris.HTML("./views", ".html")) + + sess := sessions.New(sessions.Config{Cookie: "session_cookie", AllowReclaim: true}) + app.Use(sess.Handler()) + // ^ use app.UseRouter instead to access sessions on HTTP errors too. + + // Register our custom middleware, after the sessions middleware. + app.Use(setSessionViewData) + + app.Get("/", index) + app.Listen(":8080") +} + +func setSessionViewData(ctx iris.Context) { + session := sessions.Get(ctx) + ctx.ViewData("session", session) + ctx.Next() +} + +func index(ctx iris.Context) { + session := sessions.Get(ctx) + session.Set("username", "kataras") + ctx.View("index") + /* OR without middleware: + ctx.View("index", iris.Map{ + "session": session, + // {{.session.Get "username"}} + // OR to pass only the 'username': + // "username": session.Get("username"), + // {{.username}} + }) + */ +} diff --git a/_examples/sessions/viewdata/views/index.html b/_examples/sessions/viewdata/views/index.html new file mode 100644 index 000000000..f93e5a382 --- /dev/null +++ b/_examples/sessions/viewdata/views/index.html @@ -0,0 +1,11 @@ + + + + + + Sessions View Data + + + Hello {{.session.Get "username"}} + + \ No newline at end of file diff --git a/aliases.go b/aliases.go index cd49bec78..b21ace34a 100644 --- a/aliases.go +++ b/aliases.go @@ -59,6 +59,10 @@ type ( Filter = context.Filter // A Map is an alias of map[string]interface{}. Map = context.Map + // User is a generic view of an authorized client. + // See `Context.User` and `SetUser` methods for more. + // An alias for the `context/User` type. + User = context.User // Problem Details for HTTP APIs. // Pass a Problem value to `context.Problem` to // write an "application/problem+json" response. @@ -475,8 +479,6 @@ var ( // on post data, versioning feature and others. // An alias of `context.ErrNotFound`. ErrNotFound = context.ErrNotFound - // IsErrPrivate reports whether the given "err" is a private one. - IsErrPrivate = context.IsErrPrivate // NewProblem returns a new Problem. // Head over to the `Problem` type godoc for more. // @@ -502,6 +504,9 @@ var ( // // A shortcut for the `context#ErrPushNotSupported`. ErrPushNotSupported = context.ErrPushNotSupported + // PrivateError accepts an error and returns a wrapped private one. + // A shortcut for the `context#PrivateError`. + PrivateError = context.PrivateError ) // HTTP Methods copied from `net/http`. diff --git a/configuration.go b/configuration.go index e653e7df0..bc9f04e8e 100644 --- a/configuration.go +++ b/configuration.go @@ -715,7 +715,7 @@ type Configuration struct { // The body will not be changed and existing data before the // context.UnmarshalBody/ReadJSON/ReadXML will be not consumed. // - // See `Context.RecordBody` method for the same feature, per-request. + // See `Context.RecordRequestBody` method for the same feature, per-request. DisableBodyConsumptionOnUnmarshal bool `ini:"disable_body_consumption" json:"disableBodyConsumptionOnUnmarshal,omitempty" yaml:"DisableBodyConsumptionOnUnmarshal" toml:"DisableBodyConsumptionOnUnmarshal"` // FireEmptyFormError returns if set to tue true then the `context.ReadBody/ReadForm` // will return an `iris.ErrEmptyForm` on empty request form data. diff --git a/context/context.go b/context/context.go index 42b623932..714980102 100644 --- a/context/context.go +++ b/context/context.go @@ -714,9 +714,10 @@ func (ctx *Context) StopWithError(statusCode int, err error) { } ctx.SetErr(err) - if IsErrPrivate(err) { - // error is private, we can't render it, instead . - // let the error handler render the code text. + if _, ok := err.(ErrPrivate); ok { + // error is private, we SHOULD not render it, + // leave the error handler alone to + // render the code's text instead. ctx.StopWithStatus(statusCode) return } @@ -2129,11 +2130,11 @@ func GetBody(r *http.Request, resetBody bool) ([]byte, error) { const disableRequestBodyConsumptionContextKey = "iris.request.body.record" -// RecordBody same as the Application's DisableBodyConsumptionOnUnmarshal configuration field -// but acts for the current request. +// RecordRequestBody same as the Application's DisableBodyConsumptionOnUnmarshal +// configuration field but acts only for the current request. // It makes the request body readable more than once. -func (ctx *Context) RecordBody() { - ctx.values.Set(disableRequestBodyConsumptionContextKey, true) +func (ctx *Context) RecordRequestBody(b bool) { + ctx.values.Set(disableRequestBodyConsumptionContextKey, b) } // IsRecordingBody reports whether the request body can be readen multiple times. @@ -2145,7 +2146,7 @@ func (ctx *Context) IsRecordingBody() bool { // GetBody reads and returns the request body. // The default behavior for the http request reader is to consume the data readen // but you can change that behavior by passing the `WithoutBodyConsumptionOnUnmarshal` Iris option -// or by calling the `RecordBody` method. +// or by calling the `RecordRequestBody` method. // // However, whenever you can use the `ctx.Request().Body` instead. func (ctx *Context) GetBody() ([]byte, error) { @@ -2358,6 +2359,30 @@ func (ctx *Context) ReadParams(ptr interface{}) error { return ctx.app.Validate(ptr) } +// ReadURL is a shortcut of ReadParams and ReadQuery. +// It binds dynamic path parameters and URL query parameters +// to the "ptr" pointer struct value. +// The struct fields may contain "url" or "param" binding tags. +// If a validator exists then it validates the result too. +func (ctx *Context) ReadURL(ptr interface{}) error { + values := make(map[string][]string, ctx.params.Len()) + ctx.params.Visit(func(key string, value string) { + values[key] = strings.Split(value, "/") + }) + + for k, v := range ctx.getQuery() { + values[k] = append(values[k], v...) + } + + // Decode using all available binding tags (url, header, param). + err := schema.Decode(values, ptr) + if err != nil { + return err + } + + return ctx.app.Validate(ptr) +} + // ReadProtobuf binds the body to the "ptr" of a proto Message and returns any error. // Look `ReadJSONProtobuf` too. func (ctx *Context) ReadProtobuf(ptr proto.Message) error { @@ -2409,7 +2434,28 @@ func (ctx *Context) ReadMsgPack(ptr interface{}) error { // If a GET method request then it reads from a form (or URL Query), otherwise // it tries to match (depending on the request content-type) the data format e.g. // JSON, Protobuf, MsgPack, XML, YAML, MultipartForm and binds the result to the "ptr". +// As a special case if the "ptr" was a pointer to string or []byte +// then it will bind it to the request body as it is. func (ctx *Context) ReadBody(ptr interface{}) error { + + // If the ptr is string or byte, read the body as it's. + switch v := ptr.(type) { + case *string: + b, err := ctx.GetBody() + if err != nil { + return err + } + + *v = string(b) + case *[]byte: + b, err := ctx.GetBody() + if err != nil { + return err + } + + copy(*v, b) + } + if ctx.Method() == http.MethodGet { if ctx.Request().URL.RawQuery != "" { // try read from query. @@ -5065,8 +5111,6 @@ func (ctx *Context) IsDebug() bool { return ctx.app.IsDebug() } -const errorContextKey = "iris.context.error" - // SetErr is just a helper that sets an error value // as a context value, it does nothing more. // Also, by-default this error's value is written to the client @@ -5088,14 +5132,71 @@ func (ctx *Context) SetErr(err error) { // GetErr is a helper which retrieves // the error value stored by `SetErr`. +// +// Note that, if an error was stored by `SetErrPrivate` +// then it returns the underline/original error instead +// of the internal error wrapper. func (ctx *Context) GetErr() error { + _, err := ctx.GetErrPublic() + return err +} + +// ErrPrivate if provided then the error saved in context +// should NOT be visible to the client no matter what. +type ErrPrivate interface { + error + IrisPrivateError() +} + +// An internal wrapper for the `SetErrPrivate` method. +type privateError struct{ error } + +func (e privateError) IrisPrivateError() {} + +// PrivateError accepts an error and returns a wrapped private one. +func PrivateError(err error) ErrPrivate { + if err == nil { + return nil + } + + errPrivate, ok := err.(ErrPrivate) + if !ok { + errPrivate = privateError{err} + } + + return errPrivate +} + +const errorContextKey = "iris.context.error" + +// SetErrPrivate sets an error that it's only accessible through `GetErr` +// and it should never be sent to the client. +// +// Same as ctx.SetErr with an error that completes the `ErrPrivate` interface. +// See `GetErrPublic` too. +func (ctx *Context) SetErrPrivate(err error) { + ctx.SetErr(PrivateError(err)) +} + +// GetErrPublic reports whether the stored error +// can be displayed to the client without risking +// to expose security server implementation to the client. +// +// If the error is not nil, it is always the original one. +func (ctx *Context) GetErrPublic() (bool, error) { if v := ctx.values.Get(errorContextKey); v != nil { - if err, ok := v.(error); ok { - return err + switch err := v.(type) { + case privateError: + // If it's an error set by SetErrPrivate then unwrap it. + return false, err.error + case ErrPrivate: + return false, err + case error: + return true, err } } - return nil + return false, nil } // ErrPanicRecovery may be returned from `Context` actions of a `Handler` @@ -5135,22 +5236,6 @@ func IsErrPanicRecovery(err error) (*ErrPanicRecovery, bool) { return v, ok } -// ErrPrivate if provided then the error saved in context -// should NOT be visible to the client no matter what. -type ErrPrivate interface { - IrisPrivateError() -} - -// IsErrPrivate reports whether the given "err" is a private one. -func IsErrPrivate(err error) bool { - if err == nil { - return false - } - - _, ok := err.(ErrPrivate) - return ok -} - // IsRecovered reports whether this handler has been recovered // by the Iris recover middleware. func (ctx *Context) IsRecovered() (*ErrPanicRecovery, bool) { @@ -5255,12 +5340,34 @@ func (ctx *Context) Logout(args ...interface{}) error { const userContextKey = "iris.user" -// SetUser sets a User for this request. +// SetUser sets a value as a User for this request. // It's used by auth middlewares as a common // method to provide user information to the -// next handlers in the chain. -func (ctx *Context) SetUser(u User) { +// next handlers in the chain +// Look the `User` method to retrieve it. +func (ctx *Context) SetUser(i interface{}) error { + if i == nil { + ctx.values.Remove(userContextKey) + return nil + } + + u, ok := i.(User) + if !ok { + if m, ok := i.(Map); ok { // it's a map, convert it to a User. + u = UserMap(m) + } else { + // It's a structure, wrap it and let + // runtime decide the features. + p := newUserPartial(i) + if p == nil { + return ErrNotSupported + } + u = p + } + } + ctx.values.Set(userContextKey, u) + return nil } // User returns the registered User of this request. diff --git a/context/context_user.go b/context/context_user.go index 093f3dcd6..893b86d60 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -1,8 +1,11 @@ package context import ( + "encoding/json" "errors" + "strings" "time" + "unicode" ) // ErrNotSupported is fired when a specific method is not implemented @@ -21,124 +24,458 @@ var ErrNotSupported = errors.New("not supported") // // The caller is free to cast this with the implementation directly // when special features are offered by the authorization system. +// +// To make optional some of the fields you can just embed the User interface +// and implement whatever methods you want to support. +// +// There are three builtin implementations of the User interface: +// - SimpleUser +// - UserMap (a wrapper by SetUser) +// - UserPartial (a wrapper by SetUser) type User interface { // GetAuthorization should return the authorization method, // e.g. Basic Authentication. - GetAuthorization() string + GetAuthorization() (string, error) // GetAuthorizedAt should return the exact time the // client has been authorized for the "first" time. - GetAuthorizedAt() time.Time + GetAuthorizedAt() (time.Time, error) + // GetID should return the ID of the User. + GetID() (string, error) // GetUsername should return the name of the User. - GetUsername() string + GetUsername() (string, error) // GetPassword should return the encoded or raw password // (depends on the implementation) of the User. - GetPassword() string + GetPassword() (string, error) // GetEmail should return the e-mail of the User. - GetEmail() string + GetEmail() (string, error) + // GetRoles should optionally return the specific user's roles. + // Returns `ErrNotSupported` if this method is not + // implemented by the User implementation. + GetRoles() ([]string, error) + // GetToken should optionally return a token used + // to authorize this User. + GetToken() ([]byte, error) + // GetField should optionally return a dynamic field + // based on its key. Useful for custom user fields. + // Keep in mind that these fields are encoded as a separate JSON key. + GetField(key string) (interface{}, error) +} /* Notes: +We could use a structure of User wrapper and separate interfaces for each of the methods +so they return ErrNotSupported if the implementation is missing it, so the `Features` +field and HasUserFeature can be omitted and +add a Raw() interface{} to return the underline User implementation too. +The advandages of the above idea is that we don't have to add new methods +for each of the builtin features and we can keep the (assumed) struct small. +But we dont as it has many disadvantages, unless is requested. +^ UPDATE: this is done through UserPartial. + +The disadvantage of the current implementation is that the developer MUST +complete the whole interface in order to be a valid User and if we add +new methods in the future their implementation will break +(unless they have a static interface implementation check as we have on SimpleUser). +We kind of by-pass this disadvantage by providing a SimpleUser which can be embedded (as pointer) +to the end-developer's custom implementations. +*/ + +// SimpleUser is a simple implementation of the User interface. +type SimpleUser struct { + Authorization string `json:"authorization,omitempty"` + AuthorizedAt time.Time `json:"authorized_at,omitempty"` + ID string `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"-"` + Email string `json:"email,omitempty"` + Roles []string `json:"roles,omitempty"` + Token json.RawMessage `json:"token,omitempty"` + Fields Map `json:"fields,omitempty"` +} + +var _ User = (*SimpleUser)(nil) + +// GetAuthorization returns the authorization method, +// e.g. Basic Authentication. +func (u *SimpleUser) GetAuthorization() (string, error) { + return u.Authorization, nil } -// FeaturedUser optional interface that a User can implement. -type FeaturedUser interface { - User - // GetFeatures should optionally return a list of features - // the User implementation offers. - GetFeatures() []UserFeature +// GetAuthorizedAt returns the exact time the +// client has been authorized for the "first" time. +func (u *SimpleUser) GetAuthorizedAt() (time.Time, error) { + return u.AuthorizedAt, nil } -// UserFeature a type which represents a user's optional feature. -// See `HasUserFeature` function for more. -type UserFeature uint32 +// GetID returns the ID of the User. +func (u *SimpleUser) GetID() (string, error) { + return u.ID, nil +} -// The list of standard UserFeatures. -const ( - AuthorizedAtFeature UserFeature = iota - UsernameFeature - PasswordFeature - EmailFeature -) +// GetUsername returns the name of the User. +func (u *SimpleUser) GetUsername() (string, error) { + return u.Username, nil +} -// HasUserFeature reports whether the "u" User -// implements a specific "feature" User Feature. +// GetPassword returns the raw password of the User. +func (u *SimpleUser) GetPassword() (string, error) { + return u.Password, nil +} + +// GetEmail returns the e-mail of (string,error) User. +func (u *SimpleUser) GetEmail() (string, error) { + return u.Email, nil +} + +// GetRoles returns the specific user's roles. +// Returns with `ErrNotSupported` if the Roles field is not initialized. +func (u *SimpleUser) GetRoles() ([]string, error) { + if u.Roles == nil { + return nil, ErrNotSupported + } + + return u.Roles, nil +} + +// GetToken returns the token associated with this User. +// It may return empty if the User is not featured with a Token. // -// It returns ErrNotSupported if a user does not implement -// the FeaturedUser interface. -func HasUserFeature(user User, feature UserFeature) (bool, error) { - if u, ok := user.(FeaturedUser); ok { - for _, f := range u.GetFeatures() { - if f == feature { - return true, nil - } +// The implementation can change that behavior. +// Returns with `ErrNotSupported` if the Token field is empty. +func (u *SimpleUser) GetToken() ([]byte, error) { + if len(u.Token) == 0 { + return nil, ErrNotSupported + } + + return u.Token, nil +} + +// GetField optionally returns a dynamic field from the `Fields` field +// based on its key. +func (u *SimpleUser) GetField(key string) (interface{}, error) { + if u.Fields == nil { + return nil, ErrNotSupported + } + + return u.Fields[key], nil +} + +// UserMap can be used to convert a common map[string]interface{} to a User. +// Usage: +// user := map[string]interface{}{ +// "username": "kataras", +// "age" : 27, +// } +// ctx.SetUser(user) +// OR +// user := UserStruct{....} +// ctx.SetUser(user) +// [...] +// username, err := ctx.User().GetUsername() +// field,err := ctx.User().GetField("age") +// age := field.(int) +// OR cast it: +// user := ctx.User().(UserMap) +// username := user["username"].(string) +// age := user["age"].(int) +type UserMap Map + +var _ User = UserMap{} + +// GetAuthorization returns the authorization or Authorization value of the map. +func (u UserMap) GetAuthorization() (string, error) { + return u.str("authorization") +} + +// GetAuthorizedAt returns the authorized_at or Authorized_At value of the map. +func (u UserMap) GetAuthorizedAt() (time.Time, error) { + return u.time("authorized_at") +} + +// GetID returns the id or Id or ID value of the map. +func (u UserMap) GetID() (string, error) { + return u.str("id") +} + +// GetUsername returns the username or Username value of the map. +func (u UserMap) GetUsername() (string, error) { + return u.str("username") +} + +// GetPassword returns the password or Password value of the map. +func (u UserMap) GetPassword() (string, error) { + return u.str("password") +} + +// GetEmail returns the email or Email value of the map. +func (u UserMap) GetEmail() (string, error) { + return u.str("email") +} + +// GetRoles returns the roles or Roles value of the map. +func (u UserMap) GetRoles() ([]string, error) { + return u.strSlice("roles") +} + +// GetToken returns the roles or Roles value of the map. +func (u UserMap) GetToken() ([]byte, error) { + return u.bytes("token") +} + +// GetField returns the raw map's value based on its "key". +// It's not kind of useful here as you can just use the map. +func (u UserMap) GetField(key string) (interface{}, error) { + return u[key], nil +} + +func (u UserMap) val(key string) interface{} { + isTitle := unicode.IsTitle(rune(key[0])) // if starts with uppercase. + if isTitle { + key = strings.ToLower(key) + } + + return u[key] +} + +func (u UserMap) bytes(key string) ([]byte, error) { + if v := u.val(key); v != nil { + switch s := v.(type) { + case []byte: + return s, nil + case string: + return []byte(s), nil } + } - return false, nil + return nil, ErrNotSupported +} + +func (u UserMap) str(key string) (string, error) { + if v := u.val(key); v != nil { + if s, ok := v.(string); ok { + return s, nil + } + + // exists or not we don't care, if it's invalid type we don't fill it. } - return false, ErrNotSupported + return "", ErrNotSupported } -// SimpleUser is a simple implementation of the User interface. -type SimpleUser struct { - Authorization string `json:"authorization"` - AuthorizedAt time.Time `json:"authorized_at"` - Username string `json:"username"` - Password string `json:"-"` - Email string `json:"email,omitempty"` - Features []UserFeature `json:"-"` +func (u UserMap) strSlice(key string) ([]string, error) { + if v := u.val(key); v != nil { + if s, ok := v.([]string); ok { + return s, nil + } + } + + return nil, ErrNotSupported } -var _ User = (*SimpleUser)(nil) +func (u UserMap) time(key string) (time.Time, error) { + if v := u.val(key); v != nil { + if t, ok := v.(time.Time); ok { + return t, nil + } + } -// GetAuthorization returns the authorization method, + return time.Time{}, ErrNotSupported +} + +type ( + userGetAuthorization interface { + GetAuthorization() string + } + + userGetAuthorizedAt interface { + GetAuthorizedAt() time.Time + } + + userGetID interface { + GetID() string + } + + userGetUsername interface { + GetUsername() string + } + + userGetPassword interface { + GetPassword() string + } + + userGetEmail interface { + GetEmail() string + } + + userGetRoles interface { + GetRoles() []string + } + + userGetToken interface { + GetToken() []byte + } + + userGetField interface { + GetField(string) interface{} + } + + // UserPartial is a User. + // It's a helper which wraps a struct value that + // may or may not complete the whole User interface. + UserPartial struct { + Raw interface{} + userGetAuthorization + userGetAuthorizedAt + userGetID + userGetUsername + userGetPassword + userGetEmail + userGetRoles + userGetToken + userGetField + } +) + +var _ User = (*UserPartial)(nil) + +func newUserPartial(i interface{}) *UserPartial { + containsAtLeastOneMethod := false + p := &UserPartial{Raw: i} + + if u, ok := i.(userGetAuthorization); ok { + p.userGetAuthorization = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetAuthorizedAt); ok { + p.userGetAuthorizedAt = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetID); ok { + p.userGetID = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetUsername); ok { + p.userGetUsername = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetPassword); ok { + p.userGetPassword = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetEmail); ok { + p.userGetEmail = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetRoles); ok { + p.userGetRoles = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetToken); ok { + p.userGetToken = u + containsAtLeastOneMethod = true + } + + if u, ok := i.(userGetField); ok { + p.userGetField = u + containsAtLeastOneMethod = true + } + + if !containsAtLeastOneMethod { + return nil + } + + return p +} + +// GetAuthorization should return the authorization method, // e.g. Basic Authentication. -func (u *SimpleUser) GetAuthorization() string { - return u.Authorization +func (u *UserPartial) GetAuthorization() (string, error) { + if v := u.userGetAuthorization; v != nil { + return v.GetAuthorization(), nil + } + + return "", ErrNotSupported } -// GetAuthorizedAt returns the exact time the +// GetAuthorizedAt should return the exact time the // client has been authorized for the "first" time. -func (u *SimpleUser) GetAuthorizedAt() time.Time { - return u.AuthorizedAt -} +func (u *UserPartial) GetAuthorizedAt() (time.Time, error) { + if v := u.userGetAuthorizedAt; v != nil { + return v.GetAuthorizedAt(), nil + } -// GetUsername returns the name of the User. -func (u *SimpleUser) GetUsername() string { - return u.Username + return time.Time{}, ErrNotSupported } -// GetPassword returns the raw password of the User. -func (u *SimpleUser) GetPassword() string { - return u.Password +// GetID should return the ID of the User. +func (u *UserPartial) GetID() (string, error) { + if v := u.userGetID; v != nil { + return v.GetID(), nil + } + + return "", ErrNotSupported } -// GetEmail returns the e-mail of the User. -func (u *SimpleUser) GetEmail() string { - return u.Email +// GetUsername should return the name of the User. +func (u *UserPartial) GetUsername() (string, error) { + if v := u.userGetUsername; v != nil { + return v.GetUsername(), nil + } + + return "", ErrNotSupported } -// GetFeatures returns a list of features -// this User implementation offers. -func (u *SimpleUser) GetFeatures() []UserFeature { - if u.Features != nil { - return u.Features +// GetPassword should return the encoded or raw password +// (depends on the implementation) of the User. +func (u *UserPartial) GetPassword() (string, error) { + if v := u.userGetPassword; v != nil { + return v.GetPassword(), nil } - var features []UserFeature + return "", ErrNotSupported +} - if !u.AuthorizedAt.IsZero() { - features = append(features, AuthorizedAtFeature) +// GetEmail should return the e-mail of the User. +func (u *UserPartial) GetEmail() (string, error) { + if v := u.userGetEmail; v != nil { + return v.GetEmail(), nil } - if u.Username != "" { - features = append(features, UsernameFeature) + return "", ErrNotSupported +} + +// GetRoles should optionally return the specific user's roles. +// Returns `ErrNotSupported` if this method is not +// implemented by the User implementation. +func (u *UserPartial) GetRoles() ([]string, error) { + if v := u.userGetRoles; v != nil { + return v.GetRoles(), nil } - if u.Password != "" { - features = append(features, PasswordFeature) + return nil, ErrNotSupported +} + +// GetToken should optionally return a token used +// to authorize this User. +func (u *UserPartial) GetToken() ([]byte, error) { + if v := u.userGetToken; v != nil { + return v.GetToken(), nil } - if u.Email != "" { - features = append(features, EmailFeature) + return nil, ErrNotSupported +} + +// GetField should optionally return a dynamic field +// based on its key. Useful for custom user fields. +// Keep in mind that these fields are encoded as a separate JSON key. +func (u *UserPartial) GetField(key string) (interface{}, error) { + if v := u.userGetField; v != nil { + return v.GetField(key), nil } - return features + return nil, ErrNotSupported } diff --git a/core/router/handler.go b/core/router/handler.go index 08195abfe..3ec5a4834 100644 --- a/core/router/handler.go +++ b/core/router/handler.go @@ -129,15 +129,14 @@ type RoutesProvider interface { // api builder } func defaultErrorHandler(ctx *context.Context) { - if err := ctx.GetErr(); err != nil { - if !context.IsErrPrivate(err) { - ctx.WriteString(err.Error()) - return - } + if ok, err := ctx.GetErrPublic(); ok { + // If an error is stored and it's not a private one + // write it to the response body. + ctx.WriteString(err.Error()) + return } - + // Otherwise, write the code's text instead. ctx.WriteString(context.StatusText(ctx.GetStatusCode())) - } func (h *routerHandler) Build(provider RoutesProvider) error { diff --git a/go.mod b/go.mod index fd3c1ee95..20a4c918d 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.0.3 + 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.2.3 + 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,26 +21,26 @@ 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.5 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.7 - 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-20201002170205-7f63de1d35b0 - golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c - golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f - 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.61.0 + gopkg.in/ini.v1 v1.62.0 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) diff --git a/hero/container.go b/hero/container.go index a9b142b1a..ded50532c 100644 --- a/hero/container.go +++ b/hero/container.go @@ -170,9 +170,23 @@ var BuiltinDependencies = []*Dependency{ NewDependency(func(ctx *context.Context) Code { return Code(ctx.GetStatusCode()) }).Explicitly(), + // Context Error. May be nil NewDependency(func(ctx *context.Context) Err { - return Err(ctx.GetErr()) + err := ctx.GetErr() + if err == nil { + return nil + } + return err }).Explicitly(), + // Context User, e.g. from basic authentication. + NewDependency(func(ctx *context.Context) context.User { + u := ctx.User() + if u == nil { + return nil + } + + return u + }), // payload and param bindings are dynamically allocated and declared at the end of the `binding` source file. } diff --git a/httptest/httptest.go b/httptest/httptest.go index cbd7edc32..4a5a6a18b 100644 --- a/httptest/httptest.go +++ b/httptest/httptest.go @@ -67,11 +67,11 @@ var ( } } - // LogLevel sets the application's log level "val". + // LogLevel sets the application's log level. // Defaults to disabled when testing. - LogLevel = func(val string) OptionSet { + LogLevel = func(level string) OptionSet { return func(c *Configuration) { - c.LogLevel = val + c.LogLevel = level } } ) diff --git a/middleware/README.md b/middleware/README.md index 3c0469325..2f11609e7 100644 --- a/middleware/README.md +++ b/middleware/README.md @@ -32,7 +32,6 @@ Most of the experimental handlers are ported to work with _iris_'s handler form, | [casbin](https://github.com/iris-contrib/middleware/tree/master/casbin)| An authorization library that supports access control models like ACL, RBAC, ABAC | [iris-contrib/middleware/casbin/_examples](https://github.com/iris-contrib/middleware/tree/master/casbin/_examples) | | [sentry-go (ex. raven)](https://github.com/getsentry/sentry-go/tree/master/iris)| Sentry client in Go | [sentry-go/example/iris](https://github.com/getsentry/sentry-go/blob/master/example/iris/main.go) | | [csrf](https://github.com/iris-contrib/middleware/tree/master/csrf)| Cross-Site Request Forgery Protection | [iris-contrib/middleware/csrf/_example](https://github.com/iris-contrib/middleware/blob/master/csrf/_example/main.go) | -| [go-i18n](https://github.com/iris-contrib/middleware/tree/master/go-i18n)| i18n Iris Loader for nicksnyder/go-i18n | [iris-contrib/middleware/go-i18n/_example](https://github.com/iris-contrib/middleware/blob/master/go-i18n/_example/main.go) | | [throttler](https://github.com/iris-contrib/middleware/tree/master/throttler)| Rate limiting access to HTTP endpoints | [iris-contrib/middleware/throttler/_example](https://github.com/iris-contrib/middleware/blob/master/throttler/_example/main.go) | Third-Party Handlers diff --git a/middleware/accesslog/accesslog.go b/middleware/accesslog/accesslog.go index 090cad818..195669b33 100644 --- a/middleware/accesslog/accesslog.go +++ b/middleware/accesslog/accesslog.go @@ -718,7 +718,7 @@ func (ac *AccessLog) Handler(ctx *context.Context) { // Enable reading the request body // multiple times (route handler and this middleware). if ac.shouldReadRequestBody() { - ctx.RecordBody() + ctx.RecordRequestBody(true) } // Set the fields context value so they can be modified diff --git a/middleware/basicauth/basicauth_test.go b/middleware/basicauth/basicauth_test.go index 5ff44ab1d..2eff61dc9 100644 --- a/middleware/basicauth/basicauth_test.go +++ b/middleware/basicauth/basicauth_test.go @@ -26,7 +26,11 @@ func TestBasicAuthUseRouter(t *testing.T) { app.Get("/user_string", func(ctx iris.Context) { user := ctx.User() - ctx.Writef("%s\n%s\n%s", user.GetAuthorization(), user.GetUsername(), user.GetPassword()) + + authorization, _ := user.GetAuthorization() + username, _ := user.GetUsername() + password, _ := user.GetPassword() + ctx.Writef("%s\n%s\n%s", authorization, username, password) }) app.Get("/", func(ctx iris.Context) { diff --git a/middleware/jwt/aliases.go b/middleware/jwt/aliases.go new file mode 100644 index 000000000..475d4692f --- /dev/null +++ b/middleware/jwt/aliases.go @@ -0,0 +1,126 @@ +package jwt + +import "github.com/kataras/jwt" + +// Error values. +var ( + ErrBlocked = jwt.ErrBlocked + ErrDecrypt = jwt.ErrDecrypt + ErrExpected = jwt.ErrExpected + ErrExpired = jwt.ErrExpired + ErrInvalidKey = jwt.ErrInvalidKey + ErrIssuedInTheFuture = jwt.ErrIssuedInTheFuture + ErrMissing = jwt.ErrMissing + ErrMissingKey = jwt.ErrMissingKey + ErrNotValidYet = jwt.ErrNotValidYet + ErrTokenAlg = jwt.ErrTokenAlg + ErrTokenForm = jwt.ErrTokenForm + ErrTokenSignature = jwt.ErrTokenSignature +) + +// Signature algorithms. +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 +) + +// 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 +) + +// Type alises for the underline jwt package. +type ( + // Alg is the signature algorithm interface alias. + Alg = jwt.Alg + // Claims represents the standard claim values (as specified in RFC 7519). + Claims = jwt.Claims + // 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 + + // 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 +) + +// 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 + + // ID is a shurtcut to set jwt ID on Sign. + ID = func(id string) jwt.SignOptionFunc { + return func(c *Claims) { + c.ID = id + } + } +) + +// Shortcuts for Signing and Verifying. +var ( + Verify = jwt.Verify + VerifyEncryptedToken = jwt.VerifyEncrypted + Sign = jwt.Sign + SignEncrypted = jwt.SignEncrypted +) diff --git a/middleware/jwt/alises.go b/middleware/jwt/alises.go deleted file mode 100644 index c7e4f9c77..000000000 --- a/middleware/jwt/alises.go +++ /dev/null @@ -1,82 +0,0 @@ -package jwt - -import ( - "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/jwt" -) - -type ( - // Claims represents public 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 -) - -var ( - // NewNumericDate constructs NumericDate from time.Time value. - NewNumericDate = jwt.NewNumericDate -) - -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 -) - -// 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 -) - -// 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 -) diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go new file mode 100644 index 000000000..da1ed6e66 --- /dev/null +++ b/middleware/jwt/blocklist.go @@ -0,0 +1,31 @@ +package jwt + +import ( + "github.com/kataras/jwt" +) + +// Blocklist should hold and manage invalidated-by-server tokens. +// The `NewBlocklist` and `NewBlocklistContext` functions +// returns a memory storage of tokens, +// it is the internal "blocklist" struct. +// +// The end-developer can implement her/his own blocklist, +// 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 { + jwt.TokenValidator + + // InvalidateToken should invalidate a verified JWT token. + InvalidateToken(token []byte, c Claims) error + // Del should remove a token from the storage. + Del(key string) error + // Has should report whether a specific token exists in the storage. + Has(key string) (bool, error) + // Count should return the total amount of tokens stored. + Count() (int64, error) +} + +type blocklistConnect interface { + Connect() error + IsConnected() bool +} diff --git a/middleware/jwt/blocklist/redis/blocklist.go b/middleware/jwt/blocklist/redis/blocklist.go new file mode 100644 index 000000000..74b63cd84 --- /dev/null +++ b/middleware/jwt/blocklist/redis/blocklist.go @@ -0,0 +1,185 @@ +package redis + +import ( + "context" + "io" + "sync/atomic" + + "github.com/kataras/iris/v12/core/host" + "github.com/kataras/iris/v12/middleware/jwt" + + "github.com/go-redis/redis/v8" +) + +var defaultContext = context.Background() + +type ( + // Options is just a type alias for the go-redis Client Options. + Options = redis.Options + // ClusterOptions is just a type alias for the go-redis Cluster Client Options. + ClusterOptions = redis.ClusterOptions +) + +// Client is the interface which both +// go-redis Client and Cluster Client implements. +type Client interface { + redis.Cmdable // Commands. + io.Closer // CloseConnection. +} + +// Blocklist is a jwt.Blocklist backed by Redis. +type Blocklist struct { + // GetKey is a function which can be used how to extract + // the unique identifier for a token. + // Required. By default the token key is extracted through the claims.ID ("jti"). + GetKey func(token []byte, claims jwt.Claims) string + // Prefix the token key into the redis database. + // Note that if you can also select a different database + // through ClientOptions (or ClusterOptions). + // Defaults to empty string (no prefix). + Prefix string + // Both Client and ClusterClient implements this interface. + client Client + connected uint32 + // Customize any go-redis fields manually + // before Connect. + ClientOptions Options + ClusterOptions ClusterOptions +} + +var _ jwt.Blocklist = (*Blocklist)(nil) + +// NewBlocklist returns a new redis-based Blocklist. +// Modify its ClientOptions or ClusterOptions depending the application needs +// and call its Connect. +// +// Usage: +// +// blocklist := NewBlocklist() +// blocklist.ClientOptions.Addr = ... +// err := blocklist.Connect() +// +// And register it: +// +// verifier := jwt.NewVerifier(...) +// verifier.Blocklist = blocklist +func NewBlocklist() *Blocklist { + return &Blocklist{ + GetKey: defaultGetKey, + Prefix: "", + ClientOptions: Options{ + Addr: "127.0.0.1:6379", + // The rest are defaulted to good values already. + }, + // If its Addrs > 0 before connect then cluster client is used instead. + ClusterOptions: ClusterOptions{}, + } +} + +func defaultGetKey(_ []byte, claims jwt.Claims) string { + return claims.ID +} + +// Connect prepares the redis client and fires a ping response to it. +func (b *Blocklist) Connect() error { + if b.Prefix != "" { + getKey := b.GetKey + b.GetKey = func(token []byte, claims jwt.Claims) string { + return b.Prefix + getKey(token, claims) + } + } + + if len(b.ClusterOptions.Addrs) > 0 { + // Use cluster client. + b.client = redis.NewClusterClient(&b.ClusterOptions) + } else { + b.client = redis.NewClient(&b.ClientOptions) + } + + _, err := b.client.Ping(defaultContext).Result() + if err != nil { + return err + } + + host.RegisterOnInterrupt(func() { + atomic.StoreUint32(&b.connected, 0) + b.client.Close() + }) + atomic.StoreUint32(&b.connected, 1) + + return nil +} + +// IsConnected reports whether the Connect function was called. +func (b *Blocklist) IsConnected() bool { + return atomic.LoadUint32(&b.connected) > 0 +} + +// ValidateToken checks if the token exists and +func (b *Blocklist) ValidateToken(token []byte, c jwt.Claims, err error) error { + if err != nil { + if err == jwt.ErrExpired { + b.Del(b.GetKey(token, c)) + } + + return err // respect the previous error. + } + + has, err := b.Has(b.GetKey(token, c)) + if err != nil { + return err + } else if has { + return jwt.ErrBlocked + } + + return nil +} + +// InvalidateToken invalidates a verified JWT token. +func (b *Blocklist) InvalidateToken(token []byte, c jwt.Claims) error { + key := b.GetKey(token, c) + return b.client.SetEX(defaultContext, key, token, c.Timeleft()).Err() +} + +// Del removes a token from the storage. +func (b *Blocklist) Del(key string) error { + return b.client.Del(defaultContext, key).Err() +} + +// Has reports whether a specific token exists in the storage. +func (b *Blocklist) Has(key string) (bool, error) { + n, err := b.client.Exists(defaultContext, key).Result() + return n > 0, err +} + +// Count returns the total amount of tokens stored. +func (b *Blocklist) Count() (int64, error) { + if b.Prefix == "" { + return b.client.DBSize(defaultContext).Result() + } + + keys, err := b.getKeys(0) + if err != nil { + return 0, err + } + + return int64(len(keys)), nil +} + +func (b *Blocklist) getKeys(cursor uint64) ([]string, error) { + keys, cursor, err := b.client.Scan(defaultContext, cursor, b.Prefix+"*", 300000).Result() + if err != nil { + return nil, err + } + + if cursor != 0 { + moreKeys, err := b.getKeys(cursor) + if err != nil { + return nil, err + } + + keys = append(keys, moreKeys...) + } + + return keys, nil +} diff --git a/middleware/jwt/extractor.go b/middleware/jwt/extractor.go new file mode 100644 index 000000000..a04dcb98b --- /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 196acd115..e2adc3128 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -1,613 +1,7 @@ package jwt -import ( - "crypto" - "encoding/json" - "errors" - "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 - 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. -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{} -} - -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. -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 -} - -// Expiry returns a new standard Claims with -// the `Expiry` and `IssuedAt` fields of the "claims" filled -// based on the given "maxAge" duration. -// -// See the `JWT.Expiry` method too. -func Expiry(maxAge time.Duration, claims Claims) Claims { - now := time.Now() - claims.Expiry = NewNumericDate(now.Add(maxAge)) - claims.IssuedAt = NewNumericDate(now) - return claims -} - -// Expiry method same as `Expiry` package-level function, -// it returns a Claims with the expiration fields of the "claims" -// filled based on the JWT's `MaxAge` field. -// Only use it when this standard "claims" -// is embedded on a custom claims structure. -// Usage: -// type UserClaims struct { -// jwt.Claims -// Username string -// } -// [...] -// standardClaims := j.Expiry(jwt.Claims{...}) -// customClaims := UserClaims{ -// Claims: standardClaims, -// Username: "kataras", -// } -// j.WriteToken(ctx, customClaims) -func (j *JWT) Expiry(claims Claims) Claims { - return Expiry(j.MaxAge, claims) -} - -// Token generates and returns a new token string. -// See `VerifyToken` too. -func (j *JWT) Token(claims interface{}) (string, error) { - // switch c := claims.(type) { - // case Claims: - // claims = Expiry(j.MaxAge, c) - // case map[string]interface{}: let's not support map. - // now := time.Now() - // c["iat"] = now.Unix() - // c["exp"] = now.Add(j.MaxAge).Unix() - // } - if c, ok := claims.(Claims); ok { - claims = Expiry(j.MaxAge, c) - } - - var ( - token string - err error - ) - - // jwt.Builder and jwt.NestedBuilder contain same methods but they are not the same. - if j.DecriptionKey != nil { - token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(claims).CompactSerialize() - } else { - token, err = jwt.Signed(j.Signer).Claims(claims).CompactSerialize() - } - - if err != nil { - return "", err - } - - return token, nil -} - -/* Let's no support maps, typed claim is the way to go. -// validateMapClaims validates claims of map type. -func validateMapClaims(m map[string]interface{}, e jwt.Expected, leeway time.Duration) error { - if !e.Time.IsZero() { - if v, ok := m["nbf"]; ok { - if notBefore, ok := v.(NumericDate); ok { - if e.Time.Add(leeway).Before(notBefore.Time()) { - return ErrNotValidYet - } - } - } - - if v, ok := m["exp"]; ok { - if exp, ok := v.(int64); ok { - if e.Time.Add(-leeway).Before(time.Unix(exp, 0)) { - return ErrExpired - } - } - } - - if v, ok := m["iat"]; ok { - if issuedAt, ok := v.(int64); ok { - if e.Time.Add(leeway).Before(time.Unix(issuedAt, 0)) { - return ErrIssuedInTheFuture - } - } - } - } - - return 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 -} - -var ( - // ErrMissing when token cannot be extracted from the request. - ErrMissing = errors.New("token is missing") - // 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)") -) - -type ( - claimsValidator interface { - ValidateWithLeeway(e jwt.Expected, leeway time.Duration) error - } - claimsAlternativeValidator interface { // to keep iris-contrib/jwt MapClaims compatible. - Validate() error - } - claimsContextValidator interface { - Validate(ctx *context.Context) error - } -) - -// IsValidated reports whether a token is already validated through -// `VerifyToken`. It returns true when the claims are compatible -// validators: a `Claims` value or a value that implements the `Validate() error` method. -func IsValidated(ctx *context.Context) bool { // see the `ReadClaims`. - return ctx.Values().Get(needsValidationContextKey) == nil -} - -func validateClaims(ctx *context.Context, claims interface{}) (err error) { - switch c := claims.(type) { - case claimsValidator: - err = c.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 0) - case claimsAlternativeValidator: - err = c.Validate() - case claimsContextValidator: - err = c.Validate(ctx) - case *json.RawMessage: - // if the data type is raw message (json []byte) - // then it should contain exp (and iat and nbf) keys. - // Unmarshal raw message to validate against. - v := new(Claims) - err = json.Unmarshal(*c, v) - if err == nil { - return validateClaims(ctx, v) - } - default: - ctx.Values().Set(needsValidationContextKey, struct{}{}) - } - - if err != nil { - switch err { - case jwt.ErrExpired: - return ErrExpired - case jwt.ErrNotValidYet: - return ErrNotValidYet - case jwt.ErrIssuedInTheFuture: - return ErrIssuedInTheFuture - } - } - - return err -} - -// VerifyToken verifies (and decrypts) the request token, -// it also validates and binds the parsed token's claims to the "claimsPtr" (destination). -// It does return a nil error on success. -func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}) error { - var token string - - for _, extract := range j.Extractors { - if token = extract(ctx); token != "" { - break // ok we found it. - } - } - - return j.VerifyTokenString(ctx, token, claimsPtr) -} - -// VerifyTokenString verifies and unmarshals an extracted token to "claimsPtr" destination. -// The Context is required when the claims validator needs it, otherwise can be nil. -func (j *JWT) VerifyTokenString(ctx *context.Context, token string, claimsPtr interface{}) error { - if token == "" { - return ErrMissing - } - - var ( - parsedToken *jwt.JSONWebToken - err error - ) - - if j.DecriptionKey != nil { - t, cerr := jwt.ParseSignedAndEncrypted(token) - if cerr != nil { - return cerr - } - - parsedToken, err = t.Decrypt(j.DecriptionKey) - } else { - parsedToken, err = jwt.ParseSigned(token) - } - if err != nil { - return err - } - - if err = parsedToken.Claims(j.VerificationKey, claimsPtr); err != nil { - return err - } - - return validateClaims(ctx, claimsPtr) -} - -const ( - // ClaimsContextKey is the context key which the jwt claims are stored from the `Verify` method. - ClaimsContextKey = "iris.jwt.claims" - needsValidationContextKey = "iris.jwt.claims.unvalidated" -) - -// Verify is a middleware. It verifies and optionally decrypts an incoming request token. -// It does write a 401 unauthorized status code if verification or decryption failed. -// It calls the `ctx.Next` on verified requests. -// -// See `VerifyToken` instead to verify, decrypt, validate and acquire the claims at once. -// -// A call of `ReadClaims` is required to validate and acquire the jwt claims -// on the next request. -func (j *JWT) Verify(ctx *context.Context) { - var raw json.RawMessage - if err := j.VerifyToken(ctx, &raw); err != nil { - ctx.StopWithStatus(401) - return - } - - ctx.Values().Set(ClaimsContextKey, raw) - ctx.Next() -} - -// ReadClaims binds the "claimsPtr" (destination) -// to the verified (and decrypted) claims. -// The `Verify` method should be called first (registered as middleware). -func ReadClaims(ctx *context.Context, claimsPtr interface{}) error { - v := ctx.Values().Get(ClaimsContextKey) - if v == nil { - return ErrMissing - } - - raw, ok := v.(json.RawMessage) - if !ok { - return ErrMissing - } - - err := json.Unmarshal(raw, claimsPtr) - if err != nil { - return err - } - - if !IsValidated(ctx) { - // If already validated on `Verify/VerifyToken` - // then no need to perform the check again. - ctx.Values().Remove(needsValidationContextKey) - return validateClaims(ctx, claimsPtr) - } - - return nil -} - -// Get returns and validates (if not already) the claims -// stored on request context's values storage. -// -// Should be used instead of the `ReadClaims` method when -// a custom verification middleware was registered (see the `Verify` method for an example). -// -// Usage: -// j := jwt.New(...) -// [...] -// app.Use(func(ctx iris.Context) { -// var claims CustomClaims_or_jwt.Claims -// if err := j.VerifyToken(ctx, &claims); err != nil { -// ctx.StopWithStatus(iris.StatusUnauthorized) -// return -// } -// -// ctx.Values().Set(jwt.ClaimsContextKey, claims) -// ctx.Next() -// }) -// [...] -// app.Post("/restricted", func(ctx iris.Context){ -// v, err := jwt.Get(ctx) -// [handle error...] -// claims,ok := v.(CustomClaims_or_jwt.Claims) -// if !ok { -// [do you support more than one type of claims? Handle here] -// } -// [use claims...] -// }) -func Get(ctx *context.Context) (interface{}, error) { - claims := ctx.Values().Get(ClaimsContextKey) - if claims == nil { - return nil, ErrMissing - } - - if !IsValidated(ctx) { - ctx.Values().Remove(needsValidationContextKey) - err := validateClaims(ctx, claims) - if err != nil { - return nil, err - } - } - - return claims, nil -} diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index 0d30d6fd4..b65c02b7e 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,129 +10,56 @@ import ( "github.com/kataras/iris/v12/middleware/jwt" ) -type userClaims struct { - jwt.Claims - Username string -} - -const testMaxAge = 3 * 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) - }) - testWriteVerifyToken(t, j) -} - -// HMAC verification and encryption. -func TestHMAC(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - testWriteVerifyToken(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) - } +var testAlg, testSecret = jwt.HS256, []byte("sercrethatmaycontainch@r$") - testWriteVerifyToken(t, j) +type fooClaims struct { + Foo string `json:"foo"` } -// 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) - } - testWriteVerifyToken(t, j) -} - -func testWriteVerifyToken(t *testing.T, j *jwt.JWT) { - t.Helper() - - j.Extractors = append(j.Extractors, jwt.FromJSON("access_token")) - standardClaims := jwt.Claims{Issuer: "an-issuer", Audience: jwt.Audience{"an-audience"}} - expectedClaims := userClaims{ - Claims: j.Expiry(standardClaims), - 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.Get("/auth", func(ctx iris.Context) { - j.WriteToken(ctx, expectedClaims) - }) - - app.Post("/restricted", func(ctx iris.Context) { - var claims userClaims - if err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - ctx.JSON(claims) - }) - - app.Post("/restricted_middleware_readclaims", j.Verify, func(ctx iris.Context) { - var claims userClaims - if err := jwt.ReadClaims(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - ctx.JSON(claims) - }) - app.Post("/restricted_middleware_get", j.Verify, func(ctx iris.Context) { - claims, err := jwt.Get(ctx) + 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.StopWithStatus(iris.StatusUnauthorized) + ctx.StopWithError(iris.StatusInternalServerError, err) return } - - ctx.JSON(claims) + ctx.Write(token) }) - e := httptest.New(t, app) - - // Get token. - rawToken := e.GET("/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if rawToken == "" { - t.Fatalf("empty token") + 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) + }) - restrictedPaths := [...]string{"/restricted", "/restricted_middleware_readclaims", "/restricted_middleware_get"} - - now := time.Now() - for _, path := range restrictedPaths { - // Authorization Header. - e.POST(path).WithHeader("Authorization", "Bearer "+rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - // URL Query. - e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - // JSON Body. - e.POST(path).WithJSON(iris.Map{"access_token": rawToken}).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - // Missing "Bearer". - e.POST(path).WithHeader("Authorization", rawToken).Expect(). - Status(httptest.StatusUnauthorized) - } - expireRemDur := testMaxAge - time.Since(now) + e := httptest.New(t, app) - // Expiration. - time.Sleep(expireRemDur /* -end */) - for _, path := range restrictedPaths { - e.POST(path).WithQuery("token", rawToken).Expect().Status(httptest.StatusUnauthorized) - } + // 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 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 f40f69ab3..000000000 --- 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 000000000..583f8c00f --- /dev/null +++ b/middleware/jwt/signer.go @@ -0,0 +1,99 @@ +package jwt + +import ( + "fmt" + "time" + + "github.com/kataras/jwt" +) + +// Signer holds common options to sign and generate a token. +// Its Sign method can be used to generate a token which can be sent to the client. +// Its NewTokenPair can be used to construct a token pair (access_token, refresh_token). +// +// It does not support JWE, JWK. +type Signer struct { + Alg Alg + Key interface{} + + // MaxAge to set "exp" and "iat". + // Recommended value for access tokens: 15 minutes. + // Defaults to 0, no limit. + MaxAge time.Duration + Options []SignOption + + Encrypt func([]byte) ([]byte, error) +} + +// NewSigner accepts the signature algorithm among with its (private or shared) key +// and the max life time duration of generated tokens and returns a JWT signer. +// See its Sign method. +// +// Usage: +// +// signer := NewSigner(HS256, secret, 15*time.Minute) +// token, err := signer.Sign(userClaims{Username: "kataras"}) +func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) *Signer { + if signatureAlg == HS256 { + // A tiny helper if the end-developer uses string instead of []byte for hmac keys. + if k, ok := signatureKey.(string); ok { + signatureKey = []byte(k) + } + } + + s := &Signer{ + Alg: signatureAlg, + Key: signatureKey, + MaxAge: maxAge, + } + + if maxAge > 0 { + s.Options = []SignOption{MaxAge(maxAge)} + } + + return s +} + +// WithEncryption enables AES-GCM payload-only decryption. +func (s *Signer) WithEncryption(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 +} + +// Sign generates a new token based on the given "claims" which is valid up to "s.MaxAge". +func (s *Signer) Sign(claims interface{}, opts ...SignOption) ([]byte, error) { + if len(opts) > 0 { + opts = append(opts, s.Options...) + } else { + opts = s.Options + } + + return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, opts...) +} + +// NewTokenPair accepts the access and refresh claims plus the life time duration for the refresh token +// and generates a new token pair which can be sent to the client. +// The same token pair can be json-decoded. +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/verifier.go b/middleware/jwt/verifier.go new file mode 100644 index 000000000..b5fd75c00 --- /dev/null +++ b/middleware/jwt/verifier.go @@ -0,0 +1,263 @@ +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. +// +// It does not support JWE, JWK. +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) + // DisableContextUser disables the registration of the claims as context User. + DisableContextUser bool +} + +// NewVerifier accepts the algorithm for the token's signature among with its (public) key +// and optionally some token validators for all verify middlewares that may initialized under this Verifier. +// See its Verify method. +// +// Usage: +// +// verifier := NewVerifier(HS256, secret) +// OR +// verifier := NewVerifier(HS256, secret, Expected{Issuer: "my-app"}) +// +// claimsGetter := func() interface{} { return new(userClaims) } +// middleware := verifier.Verify(claimsGetter) +// OR +// middleware := verifier.Verify(claimsGetter, Expected{Issuer: "my-app"}) +// +// Register the middleware, e.g. +// app.Use(middleware) +// +// Get the claims: +// claims := jwt.Get(ctx).(*userClaims) +// username := claims.Username +// +// Get the context user: +// username, err := ctx.User().GetUsername() +func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...TokenValidator) *Verifier { + if signatureAlg == HS256 { + // A tiny helper if the end-developer uses string instead of []byte for hmac keys. + if k, ok := signatureKey.(string); ok { + signatureKey = []byte(k) + } + } + + 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, + } +} + +// WithDecryption enables AES-GCM payload-only encryption. +func (v *Verifier) WithDecryption(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) + 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. +// +// By default it extracts the token from Authorization: Bearer $token header and ?token URL Query parameter, +// to change that behavior modify its Extractors field. +// +// By default a 401 status code with a generic message will be sent to the client on +// a token verification or claims validation failure, to change that behavior +// modify its ErrorHandler field or register OnErrorCode(401, errorHandler) and +// retrieve the error through Context.GetErr method. +// +// If the "claimsType" is nil then only the jwt.GetVerifiedToken is available +// and the handler should unmarshal the payload to extract the claims by itself. +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 { + // If blocklist implements the connect interface, + // try to connect if it's not already connected manually by developer, + // if errored then just return a handler which will fire this error every single time. + if bc, ok := v.Blocklist.(blocklistConnect); ok { + if !bc.IsConnected() { + if err := bc.Connect(); err != nil { + return func(ctx *context.Context) { + v.ErrorHandler(ctx, err) + } + } + } + } + + 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 !v.DisableContextUser { + ctx.SetUser(dest) + } + + 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 +} diff --git a/sessions/sessiondb/redis/database.go b/sessions/sessiondb/redis/database.go index 6eabee94a..1773a208a 100644 --- a/sessions/sessiondb/redis/database.go +++ b/sessions/sessiondb/redis/database.go @@ -16,7 +16,7 @@ const ( DefaultRedisNetwork = "tcp" // DefaultRedisAddr the redis address option, "127.0.0.1:6379". DefaultRedisAddr = "127.0.0.1:6379" - // DefaultRedisTimeout the redis idle timeout option, time.Duration(30) * time.Second + // DefaultRedisTimeout the redis idle timeout option, time.Duration(30) * time.Second. DefaultRedisTimeout = time.Duration(30) * time.Second )