From 1864f99145bfa4c865c76bc6ec26dd3c04f6250b Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 17 Oct 2020 06:40:17 +0300 Subject: [PATCH 01/19] New JWT features and changes (examples updated). Improvements on the Context User and Private Error features TODO: Write the new e-book JWT section and the HISTORY entry of the chnages and add a simple example on site docs --- HISTORY.md | 5 +- _examples/auth/jwt/main.go | 283 ++++++++-- _examples/auth/jwt/refresh-token/main.go | 189 +++---- .../request-logger/accesslog-simple/main.go | 5 +- .../sessions/overview/example/example.go | 8 +- aliases.go | 9 +- context/context.go | 88 ++- context/context_user.go | 236 +++++++- core/router/handler.go | 13 +- go.mod | 14 +- hero/container.go | 16 +- httptest/httptest.go | 6 +- middleware/README.md | 1 - middleware/jwt/alises.go | 9 + middleware/jwt/blocklist.go | 131 +++++ middleware/jwt/jwt.go | 515 ++++++++++-------- middleware/jwt/jwt_test.go | 239 ++++++-- middleware/jwt/user.go | 187 +++++++ middleware/jwt/validation.go | 212 +++++++ 19 files changed, 1711 insertions(+), 455 deletions(-) create mode 100644 middleware/jwt/blocklist.go create mode 100644 middleware/jwt/user.go create mode 100644 middleware/jwt/validation.go diff --git a/HISTORY.md b/HISTORY.md index 95ad69f33c..940c51cdb3 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/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` @@ -315,7 +315,7 @@ var dirOpts = iris.DirOptions{ - `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.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.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.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). @@ -490,6 +490,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/_examples/auth/jwt/main.go b/_examples/auth/jwt/main.go index 2c31091fbf..ee2ef42fc5 100644 --- a/_examples/auth/jwt/main.go +++ b/_examples/auth/jwt/main.go @@ -1,3 +1,6 @@ +// Package main shows how you can use the Iris unique JWT middleware. +// The file contains different kind of examples that all do the same job but, +// depending on your code style and your application's requirements, you may choose one over other. package main import ( @@ -7,10 +10,31 @@ import ( "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 +// Claims a custom claims structure. +type Claims struct { + // Optionally define JWT's "iss" (Issuer), + // "sub" (Subject) and "aud" (Audience) for issuer and subject. + // The JWT's "exp" (expiration) and "iat" (issued at) are automatically + // set by the middleware. + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience []string `json:"aud"` + /* + Note that the above fields can be also extracted via: + jwt.GetTokenInfo(ctx).Claims + But in that example, we just showcase how these info can be embedded + inside your own Go structure. + */ + + // Optionally define a "exp" (Expiry), + // unlike the rest, this is unset on creation + // (unless you want to override the middleware's max age option), + // it's filled automatically by the JWT middleware + // when the request token is verified. + // See the POST /user route. + Expiry *jwt.NumericDate `json:"exp"` + + Username string `json:"username"` } func main() { @@ -20,56 +44,241 @@ func main() { // // 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) + + /* + By default it extracts the token from url parameter "token={token}" + and the Authorization Bearer {token} header. + You can also take token from JSON body: + j.Extractors = append(j.Extractors, jwt.FromJSON) + */ + + /* Optionally, enable block list to force-invalidate + verified tokens even before their expiration time. + This is useful when the client doesn't clear + the token on a user logout by itself. + + The duration argument clears any expired token on each every tick. + There is a GC() method that can be manually called to clear expired blocked tokens + from the memory. + + j.Blocklist = jwt.NewBlocklist(30*time.Minute) + OR NewBlocklistContext(stdContext, 30*time.Minute) + + + To invalidate a verified token just call: + j.Invalidate(ctx) inside a route handler. + */ app := iris.New() app.Logger().SetLevel("debug") + app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { + // Note that, any error stored by an authentication + // method in Iris is an iris.ErrPrivate. + // Available jwt errors: + // - ErrMissing + // - ErrMissingKey + // - ErrExpired + // - ErrNotValidYet + // - ErrIssuedInTheFuture + // - ErrBlocked + // An iris.ErrPrivate SHOULD never be displayed to the client as it is; + // because it may contain critical security information about the server. + // + // Also keep in mind that JWT middleware logs verification errors to the + // application's logger ("debug") so, normally you don't have to + // bother showing the verification error to the browser/client. + // However, you can retrieve that error and do what ever you feel right: + if err := ctx.GetErr(); err != nil { + // If we have an error stored, + // (JWT middleware stores any verification errors to the Context), + // set the error as response body, + // which is the default behavior if that + // wasn't an authentication error (as explained above) + ctx.WriteString(err.Error()) + } else { + // Else, the default behavior when no error was occured; + // write the status text of the status code: + ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) + } + }) + app.Get("/authenticate", func(ctx iris.Context) { - 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), + claims := &Claims{ + Issuer: "server", + Audience: []string{"user"}, Username: "kataras", } - j.WriteToken(ctx, customClaims) + // WriteToken generates and sends the token to the client. + // To generate a token use: tok, err := j.Token(claims) + // then you can write it in any form you'd like. + // The expiration JWT fields are automatically + // set by the middleware, that means that your claims value + // only needs to fill fields that your application specifically requires. + j.WriteToken(ctx, claims) }) - userRouter := app.Party("/user") + // Middleware + type-safe method, + // useful in 99% of the cases, when your application + // requires token verification under a whole path prefix, e.g. /protected: + protectedAPI := app.Party("/protected") { - // 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()) + protectedAPI.Use(j.Verify(func() interface{} { + // Must return a pointer to a type. + // + // The Iris JWT implementation is very sophisticated. + // We keep our claims in type-safe form. + // However, you are free to use raw Go maps + // (map[string]interface{} or iris.Map) too (example later on). + // + // Note that you can use the same "j" JWT instance + // to serve different types of claims on other group of routes, + // e.g. postRouter.Use(j.Verify(... return new(Post))). + return new(Claims) + })) + + protectedAPI.Get("/", func(ctx iris.Context) { + claims := jwt.Get(ctx).(*Claims) + // All fields parsed from token are set to the claims, + // including the Expiry (if defined). + ctx.Writef("Username: %s\nExpires at: %s\nAudience: %s", + claims.Username, claims.Expiry.Time(), claims.Audience) + }) + } + + // Verify token inside a handler method, + // useful when you just need to verify a token on a single spot: + app.Get("/inline", func(ctx iris.Context) { + var claims Claims + _, err := j.VerifyToken(ctx, &claims) + if err != nil { + ctx.StopWithError(iris.StatusUnauthorized, err) + return + } + + ctx.Writef("Username: %s\nExpires at: %s\n", + claims.Username, claims.Expiry.Time()) + }) + + // Use a common map as claims method, + // not recommended, as we support typed claims but + // you can do it: + app.Get("/map/authenticate", func(ctx iris.Context) { + claims := map[string]interface{}{ // or iris.Map for shortcut. + "username": "kataras", + } + + j.WriteToken(ctx, claims) + }) + + app.Get("/map/verify/middleware", j.Verify(func() interface{} { + return &iris.Map{} // or &map[string]interface{}{} + }), func(ctx iris.Context) { + claims := jwt.Get(ctx).(iris.Map) + // The Get method will unwrap the *iris.Map for you, + // so its values are directly accessible: + ctx.Writef("Username: %s\nExpires at: %s\n", + claims["username"], claims["exp"].(*jwt.NumericDate).Time()) + }) + + app.Get("/map/verify", func(ctx iris.Context) { + claims := make(iris.Map) // or make(map[string]interface{}) + + tokenInfo, err := j.VerifyToken(ctx, &claims) + if err != nil { + ctx.StopWithError(iris.StatusUnauthorized, err) + return + } + + ctx.Writef("Username: %s\nExpires at: %s\n", + claims["username"], tokenInfo.Claims.Expiry.Time()) /* the claims["exp"] is also set. */ + }) + + // Use the new Context.User() to retrieve the verified client method: + // 1. Create a go stuct that implements the context.User interface: + app.Get("/users/authenticate", func(ctx iris.Context) { + user := &User{Username: "kataras"} + j.WriteToken(ctx, user) + }) + usersAPI := app.Party("/users") + { + usersAPI.Use(j.Verify(func() interface{} { + return new(User) + })) + + usersAPI.Get("/", func(ctx iris.Context) { + user := ctx.User() + userToken, _ := user.GetToken() + /* + You can also cast it to the underline implementation + and work with its fields: + expires := user.(*User).Expiry.Time() + */ + // OR use the GetTokenInfo to get the parsed token information: + expires := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() + lifetime := expires.Sub(time.Now()) // remeaning time to be expired. + + ctx.Writef("Username: %s\nAuthenticated at: %s\nLifetime: %s\nToken: %s\n", + user.GetUsername(), user.GetAuthorizedAt(), lifetime, userToken) }) } + // http://localhost:8080/authenticate + // http://localhost:8080/protected?token={token} + // http://localhost:8080/inline?token={token} + // + // http://localhost:8080/map/authenticate + // http://localhost:8080/map/verify?token={token} + // http://localhost:8080/map/verify/middleware?token={token} + // + // http://localhost:8080/users/authenticate + // http://localhost:8080/users?token={token} app.Listen(":8080") } +// User is a custom implementation of the Iris Context User interface. +// Optionally, for JWT, you can also implement +// the SetToken(tok string) and +// Validate(ctx iris.Context, claims jwt.Claims, e jwt.Expected) error +// methods to set a token and add custom validation +// to a User value parsed from a token. +type User struct { + iris.User + Username string `json:"username"` + + // Optionally, declare some JWT fields, + // they are automatically filled by the middleware itself. + IssuedAt *jwt.NumericDate `json:"iat"` + Expiry *jwt.NumericDate `json:"exp"` + Token string `json:"-"` +} + +// GetUsername returns the Username. +// Look the iris/context.SimpleUser type +// for all the methods you can implement. +func (u *User) GetUsername() string { + return u.Username +} + +// GetAuthorizedAt returns the IssuedAt time. +// This and the Get/SetToken methods showcase how you can map JWT standard fields +// to an Iris Context User. +func (u *User) GetAuthorizedAt() time.Time { + return u.IssuedAt.Time() +} + +// GetToken is a User interface method. +func (u *User) GetToken() (string, error) { + return u.Token, nil +} + +// SetToken is a special jwt.TokenSetter interface which is +// called automatically when a token is parsed to this User value. +func (u *User) SetToken(tok string) { + u.Token = tok +} + /* func default_RSA_Example() { j := jwt.RSA(15*time.Minute) diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index 9e3be184c6..4f41c22d03 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -7,133 +7,122 @@ import ( "github.com/kataras/iris/v12/middleware/jwt" ) -// UserClaims a custom claims structure. You can just use jwt.Claims too. +// UserClaims a custom access claims structure. type UserClaims struct { - jwt.Claims - Username string + // We could that JWT field to separate the access and refresh token: + // Issuer string `json:"iss"` + // But let's cover the "required" feature too, see below: + ID string `json:"user_id,required"` + Username string `json:"username,required"` } -// TokenPair holds the access token and refresh token response. -type TokenPair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} +// For refresh token, we will just use the jwt.Claims +// structure which contains the standard JWT fields. func main() { app := iris.New() - // 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"), - } + j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - // 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 - } - - ctx.JSON(tokenPair) + generateTokenPair(ctx, j) }) - 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 - } - - userID := claims.Subject - if userID == "" { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - // Simulate a database call against our jwt subject. - if userID != "53afcf05-38a3-43c3-82af-8bbbe0e4a149" { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - // All OK, re-generate the new pair and send to client. - tokenPair, err := generateTokenPair(accessJWT, refreshJWT) - if err != nil { - ctx.StopWithStatus(iris.StatusInternalServerError) - return - } - - ctx.JSON(tokenPair) + app.Get("/refresh_json", func(ctx iris.Context) { + refreshTokenFromJSON(ctx, j) }) - app.Get("/", func(ctx iris.Context) { - var claims UserClaims - if err := accessJWT.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, claims.Expiry.Time()) - }) + protectedAPI := app.Party("/protected") + { + protectedAPI.Use(j.Verify(func() interface{} { + return new(UserClaims) + })) // OR j.VerifyToken(ctx, &claims, jwt.MeetRequirements(&UserClaims{})) + + protectedAPI.Get("/", func(ctx iris.Context) { + // Get token info, even if our UserClaims does not embed those + // through GetTokenInfo: + expiresAt := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() + // Get your custom JWT claims through Get, + // which is a shortcut of GetTokenInfo(ctx).Value: + claims := jwt.Get(ctx).(*UserClaims) + + ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, expiresAt) + }) + } - // http://localhost:8080 (401) + // http://localhost:8080/protected (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}) + // http://localhost:8080/protected?token={access_token} (200) + // http://localhost:8080/protected?token={refresh_token} (401) + // http://localhost:8080/refresh_json (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) app.Listen(":8080") } -func generateTokenPair(accessJWT, refreshJWT *jwt.JWT) (TokenPair, error) { - standardClaims := jwt.Claims{Issuer: "an-issuer", Audience: jwt.Audience{"an-audience"}} +func generateTokenPair(ctx iris.Context, j *jwt.JWT) { + // Simulate a user... + userID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" - customClaims := UserClaims{ - Claims: accessJWT.Expiry(standardClaims), + // Map the current user with the refresh token, + // so we make sure, on refresh route, that this refresh token owns + // to that user before re-generate. + refresh := jwt.Claims{Subject: userID} + + access := UserClaims{ + ID: userID, Username: "kataras", } - accessToken, err := accessJWT.Token(customClaims) + // Generates a Token Pair, long-live for refresh tokens, e.g. 1 hour. + // Second argument is the refresh claims and, + // the last one is the access token's claims. + tokenPair, err := j.TokenPair(1*time.Hour, refresh, access) if err != nil { - return TokenPair{}, err + ctx.Application().Logger().Debugf("token pair: %v", err) + ctx.StopWithStatus(iris.StatusInternalServerError) + 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", - }) + // Send the generated token pair to the client. + // The tokenPair looks like: {"access_token": $token, "refresh_token": $token} + ctx.JSON(tokenPair) +} + +func refreshTokenFromJSON(ctx iris.Context, j *jwt.JWT) { + var tokenPair jwt.TokenPair + + // Grab the refresh token from a JSON body (you can let it fetch by URL parameter too but + // it's common practice that you read it from a json body as + // it may contain the access token too (the same response we sent on generateTokenPair)). + err := ctx.ReadJSON(&tokenPair) + if err != nil { + ctx.StatusCode(iris.StatusBadRequest) + return + } - refreshToken, err := refreshJWT.Token(refreshClaims) + var refreshClaims jwt.Claims + err = j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims) if err != nil { - return TokenPair{}, err + ctx.Application().Logger().Debugf("verify refresh token: %v", err) + ctx.StatusCode(iris.StatusUnauthorized) + return + } + + // 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. + currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" + + userID := refreshClaims.Subject + if userID != currentUserID { + ctx.StopWithStatus(iris.StatusUnauthorized) + return } + // + // Otherwise, the request must contain the (old) access token too, + // even if it's invalid, we can still fetch its fields, such as the user id. + // [...leave it for you] - return TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - }, nil + // All OK, re-generate the new pair and send to client. + generateTokenPair(ctx, j) } diff --git a/_examples/logging/request-logger/accesslog-simple/main.go b/_examples/logging/request-logger/accesslog-simple/main.go index c966e2be6d..77880c8bf0 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/sessions/overview/example/example.go b/_examples/sessions/overview/example/example.go index ed4fb5e496..e47624d72c 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/aliases.go b/aliases.go index cd49bec786..b21ace34ac 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/context/context.go b/context/context.go index 42b6239327..aa15cbac0d 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 } @@ -5065,8 +5066,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 +5087,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 +5191,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) { diff --git a/context/context_user.go b/context/context_user.go index 093f3dcd69..1e27eb628c 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -2,7 +2,9 @@ package context import ( "errors" + "strings" "time" + "unicode" ) // ErrNotSupported is fired when a specific method is not implemented @@ -21,6 +23,13 @@ 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 two builtin implementations of the User interface: +// - SimpleUser (type-safe) +// - UserMap (wraps a map[string]interface{}) type User interface { // GetAuthorization should return the authorization method, // e.g. Basic Authentication. @@ -35,7 +44,33 @@ type User interface { GetPassword() string // GetEmail should return the e-mail of the User. GetEmail() string -} + // 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() (string, 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. + +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. +*/ // FeaturedUser optional interface that a User can implement. type FeaturedUser interface { @@ -55,6 +90,9 @@ const ( UsernameFeature PasswordFeature EmailFeature + RolesFeature + TokenFeature + FieldsFeature ) // HasUserFeature reports whether the "u" User @@ -80,13 +118,16 @@ func HasUserFeature(user User, feature UserFeature) (bool, error) { type SimpleUser struct { Authorization string `json:"authorization"` AuthorizedAt time.Time `json:"authorized_at"` - Username string `json:"username"` + Username string `json:"username,omitempty"` Password string `json:"-"` Email string `json:"email,omitempty"` - Features []UserFeature `json:"-"` + Roles []string `json:"roles,omitempty"` + Features []UserFeature `json:"features,omitempty"` + Token string `json:"token,omitempty"` + Fields Map `json:"fields,omitempty"` } -var _ User = (*SimpleUser)(nil) +var _ FeaturedUser = (*SimpleUser)(nil) // GetAuthorization returns the authorization method, // e.g. Basic Authentication. @@ -115,6 +156,39 @@ func (u *SimpleUser) GetEmail() string { return u.Email } +// 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. +// +// The implementation can change that behavior. +// Returns with `ErrNotSupported` if the Token field is empty. +func (u *SimpleUser) GetToken() (string, error) { + if u.Token == "" { + return "", 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 +} + // GetFeatures returns a list of features // this User implementation offers. func (u *SimpleUser) GetFeatures() []UserFeature { @@ -140,5 +214,159 @@ func (u *SimpleUser) GetFeatures() []UserFeature { features = append(features, EmailFeature) } + if u.Roles != nil { + features = append(features, RolesFeature) + } + + if u.Fields != nil { + features = append(features, FieldsFeature) + } + return features } + +// 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(UserMap(user)) +// OR +// user := UserMap{"key": "value",...} +// ctx.SetUser(user) +// [...] +// username := ctx.User().GetUsername() +// age := ctx.User().GetField("age").(int) +// OR cast it: +// user := ctx.User().(UserMap) +// username := user["username"].(string) +// age := user["age"].(int) +type UserMap Map + +var _ FeaturedUser = UserMap{} + +// GetAuthorization returns the authorization or Authorization value of the map. +func (u UserMap) GetAuthorization() string { + return u.str("authorization") +} + +// GetAuthorizedAt returns the authorized_at or Authorized_At value of the map. +func (u UserMap) GetAuthorizedAt() time.Time { + return u.time("authorized_at") +} + +// GetUsername returns the username or Username value of the map. +func (u UserMap) GetUsername() string { + return u.str("username") +} + +// GetPassword returns the password or Password value of the map. +func (u UserMap) GetPassword() string { + return u.str("password") +} + +// GetEmail returns the email or Email value of the map. +func (u UserMap) GetEmail() string { + return u.str("email") +} + +// GetRoles returns the roles or Roles value of the map. +func (u UserMap) GetRoles() ([]string, error) { + if s := u.strSlice("roles"); s != nil { + return s, nil + } + + return nil, ErrNotSupported +} + +// GetToken returns the roles or Roles value of the map. +func (u UserMap) GetToken() (string, error) { + if s := u.str("token"); s != "" { + return s, nil + } + + return "", ErrNotSupported +} + +// 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 +} + +// GetFeatures returns a list of features +// this map offers. +func (u UserMap) GetFeatures() []UserFeature { + if v := u.val("features"); v != nil { // if already contain features. + if features, ok := v.([]UserFeature); ok { + return features + } + } + + // else try to resolve from map values. + features := []UserFeature{FieldsFeature} + + if !u.GetAuthorizedAt().IsZero() { + features = append(features, AuthorizedAtFeature) + } + + if u.GetUsername() != "" { + features = append(features, UsernameFeature) + } + + if u.GetPassword() != "" { + features = append(features, PasswordFeature) + } + + if u.GetEmail() != "" { + features = append(features, EmailFeature) + } + + if roles, err := u.GetRoles(); err == nil && roles != nil { + features = append(features, RolesFeature) + } + + return features +} + +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) str(key string) string { + if v := u.val(key); v != nil { + if s, ok := v.(string); ok { + return s + } + + // exists or not we don't care, if it's invalid type we don't fill it. + } + + return "" +} + +func (u UserMap) strSlice(key string) []string { + if v := u.val(key); v != nil { + if s, ok := v.([]string); ok { + return s + } + } + + return nil +} + +func (u UserMap) time(key string) time.Time { + if v := u.val(key); v != nil { + if t, ok := v.(time.Time); ok { + return t + } + } + + return time.Time{} +} diff --git a/core/router/handler.go b/core/router/handler.go index 08195abfef..3ec5a48341 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 fd3c1ee95d..6f9f219c4e 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.0 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.1 github.com/google/uuid v1.1.2 github.com/hashicorp/go-version v1.2.1 github.com/iris-contrib/httpexpect/v2 v2.0.5 @@ -31,16 +31,16 @@ require ( 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/tdewolff/minify/v2 v2.9.9 github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 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/crypto v0.0.0-20201012173705-84dcc777aaee + golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 + golang.org/x/sys v0.0.0-20201016160150-f659759dc4ca golang.org/x/text v0.3.3 golang.org/x/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 a9b142b1ab..ded50532cf 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 cbd7edc32e..4a5a6a18b4 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 3c04693257..2f11609e73 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/jwt/alises.go b/middleware/jwt/alises.go index c7e4f9c772..da34d9c7a5 100644 --- a/middleware/jwt/alises.go +++ b/middleware/jwt/alises.go @@ -2,6 +2,7 @@ package jwt import ( "github.com/square/go-jose/v3" + "github.com/square/go-jose/v3/json" "github.com/square/go-jose/v3/jwt" ) @@ -14,11 +15,19 @@ type ( // epoch, including leap seconds. Non-integer values can be represented // in the serialized format, but we round to the nearest second. NumericDate = jwt.NumericDate + // Expected defines values used for protected claims validation. + // If field has zero value then validation is skipped. + Expected = jwt.Expected ) var ( // NewNumericDate constructs NumericDate from time.Time value. NewNumericDate = jwt.NewNumericDate + // Marshal returns the JSON encoding of v. + Marshal = json.Marshal + // Unmarshal parses the JSON-encoded data and stores the result + // in the value pointed to by v. + Unmarshal = json.Unmarshal ) type ( diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go new file mode 100644 index 0000000000..866226557a --- /dev/null +++ b/middleware/jwt/blocklist.go @@ -0,0 +1,131 @@ +package jwt + +import ( + stdContext "context" + "sync" + "time" +) + +// Blocklist is an in-memory storage of tokens that should be +// immediately invalidated by the server-side. +// The most common way to invalidate a token, e.g. on user logout, +// is to make the client-side remove the token itself. +// However, if someone else has access to that token, +// it could be still valid for new requests until its expiration. +type Blocklist struct { + entries map[string]time.Time // key = token | value = expiration time (to remove expired). + mu sync.RWMutex +} + +// NewBlocklist returns a new up and running in-memory Token Blocklist. +// The returned value can be set to the JWT instance's Blocklist field. +func NewBlocklist(gcEvery time.Duration) *Blocklist { + return NewBlocklistContext(stdContext.Background(), gcEvery) +} + +// NewBlocklistContext same as `NewBlocklist` +// but it also accepts a standard Go Context for GC cancelation. +func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) *Blocklist { + b := &Blocklist{ + entries: make(map[string]time.Time), + } + + if gcEvery > 0 { + go b.runGC(ctx, gcEvery) + } + + return b +} + +// Set upserts a given token, with its expiration time, +// to the block list, so it's immediately invalidated by the server-side. +func (b *Blocklist) Set(token string, expiresAt time.Time) { + b.mu.Lock() + b.entries[token] = expiresAt + b.mu.Unlock() +} + +// Del removes a "token" from the block list. +func (b *Blocklist) Del(token string) { + b.mu.Lock() + delete(b.entries, token) + b.mu.Unlock() +} + +// Count returns the total amount of blocked tokens. +func (b *Blocklist) Count() int { + b.mu.RLock() + n := len(b.entries) + b.mu.RUnlock() + + return n +} + +// Has reports whether the given "token" is blocked by the server. +// This method is called before the token verification, +// so even if was expired it is removed from the block list. +func (b *Blocklist) Has(token string) bool { + if token == "" { + return false + } + + b.mu.RLock() + _, ok := b.entries[token] + b.mu.RUnlock() + + /* No, the Blocklist will be used after the token is parsed, + there we can call the Del method if err was ErrExpired. + if ok { + // As an extra step, to keep the list size as small as possible, + // we delete it from list if it's going to be expired + // ~in the next `blockedExpireLeeway` seconds.~ + // - Let's keep it easier for testing by not setting a leeway. + // if time.Now().Add(blockedExpireLeeway).After(expiresAt) { + if time.Now().After(expiresAt) { + b.Del(token) + } + }*/ + + return ok +} + +// GC iterates over all entries and removes expired tokens. +// This method is helpful to keep the list size small. +// Depending on the application, the GC method can be scheduled +// to called every half or a whole hour. +// A good value for a GC cron task is the JWT's max age (default). +func (b *Blocklist) GC() int { + now := time.Now() + var markedForDeletion []string + + b.mu.RLock() + for token, expiresAt := range b.entries { + if now.After(expiresAt) { + markedForDeletion = append(markedForDeletion, token) + } + } + b.mu.RUnlock() + + n := len(markedForDeletion) + if n > 0 { + for _, token := range markedForDeletion { + b.Del(token) + } + } + + return n +} + +func (b *Blocklist) runGC(ctx stdContext.Context, every time.Duration) { + t := time.NewTicker(every) + + for { + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + b.GC() + } + } +} diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 196acd1154..37df46223f 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -2,8 +2,6 @@ package jwt import ( "crypto" - "encoding/json" - "errors" "os" "strings" "time" @@ -85,6 +83,9 @@ func FromJSON(jsonKey string) TokenExtractor { // // The `RSA(privateFile, publicFile, password)` package-level helper function // can be used to decode the SignKey and VerifyKey. +// +// For an easy use look the `HMAC` package-level function +// and the its `NewUser` and `VerifyUser` methods. type JWT struct { // MaxAge is the expiration duration of the generated tokens. MaxAge time.Duration @@ -109,6 +110,17 @@ type JWT struct { Encrypter jose.Encrypter // DecriptionKey is used to decrypt the token (private key) DecriptionKey interface{} + + // Blocklist holds the invalidated-by-server tokens (that are not yet expired). + // It is not initialized by default. + // Initialization Usage: + // j.UseBlocklist() + // OR + // j.Blocklist = jwt.NewBlocklist(gcEveryDuration) + // Usage: + // - ctx.Logout() + // - j.Invalidate(ctx) + Blocklist *Blocklist } type privateKey interface{ Public() crypto.PublicKey } @@ -284,64 +296,68 @@ func (j *JWT) WithEncryption(contentEncryption ContentEncryption, alg KeyAlgorit 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 +// UseBlocklist initializes the Blocklist. +// Should be called on jwt middleware creation-time, +// after this, the developer can use the Context.Logout method +// to invalidate a verified token by the server-side. +func (j *JWT) UseBlocklist() { + gcEvery := 30 * time.Minute + if j.MaxAge > 0 { + gcEvery = j.MaxAge + } + j.Blocklist = NewBlocklist(gcEvery) } -// 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) +// ExpiryMap adds the expiration based on the "maxAge" to the "claims" map. +// It's called automatically on `Token` method. +func ExpiryMap(maxAge time.Duration, claims context.Map) { + now := time.Now() + if claims["exp"] == nil { + claims["exp"] = NewNumericDate(now.Add(maxAge)) + } + + if claims["iat"] == nil { + claims["iat"] = NewNumericDate(now) + } } // Token generates and returns a new token string. // See `VerifyToken` too. func (j *JWT) Token(claims interface{}) (string, error) { - // 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) + return j.token(j.MaxAge, claims) +} + +func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { + if claims == nil { + return "", ErrInvalidKey + } + + c, nErr := normalize(claims) + if nErr != nil { + return "", nErr } + // Set expiration, if missing. + ExpiryMap(maxAge, c) + var ( token string err error ) - // jwt.Builder and jwt.NestedBuilder contain same methods but they are not the same. + // + // Note that the .Claims method there, converts a Struct to a map under the hoods. + // That means that we will not have any performance cost + // if we do it by ourselves and pass always a Map there. + // That gives us the option to allow user to pass ANY go struct + // and we can add the "exp", "nbf", "iat" map values by ourselves + // based on the j.MaxAge. + // (^ done, see normalize, all methods are + // changed to accept totally custom types, no need to embed the standard Claims anymore). if j.DecriptionKey != nil { - token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(claims).CompactSerialize() + token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(c).CompactSerialize() } else { - token, err = jwt.Signed(j.Signer).Claims(claims).CompactSerialize() + token, err = jwt.Signed(j.Signer).Claims(c).CompactSerialize() } if err != nil { @@ -351,39 +367,6 @@ func (j *JWT) Token(claims interface{}) (string, error) { 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. // @@ -399,91 +382,122 @@ func (j *JWT) WriteToken(ctx *context.Context, claims interface{}) error { 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)") -) +// VerifyToken verifies (and decrypts) the request token, +// it also validates and binds the parsed token's claims to the "claimsPtr" (destination). +// +// The last, variadic, input argument is optionally, if provided then the +// parsed claims must match the expectations; +// e.g. Audience, Issuer, ID, Subject. +// See `ExpectXXX` package-functions for details. +func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { + token := j.RequestToken(ctx) + return j.VerifyTokenString(ctx, token, claimsPtr, expectations...) +} -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 +// RequestToken extracts the token from the request. +func (j *JWT) RequestToken(ctx *context.Context) (token string) { + for _, extract := range j.Extractors { + if token = extract(ctx); token != "" { + break // ok we found it. + } } -) -// 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 + return +} + +// TokenSetter is an interface which if implemented +// the extracted, verified, token is stored to the object. +type TokenSetter interface { + SetToken(token string) } -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) +// TokenInfo holds the standard token information may required +// for further actions. +// This structure is mostly useful when the developer's go structure +// does not hold the standard jwt fields (e.g. "exp") +// but want access to the parsed token which contains those fields. +// Inside the middleware, it is used to invalidate tokens through server-side, see `Invalidate`. +type TokenInfo struct { + RequestToken string // The request token. + Claims Claims // The standard JWT parsed fields from the request Token. + Value interface{} // The pointer to the end-developer's custom claims structure (see `Get`). +} + +const tokenInfoContextKey = "iris.jwt.token" + +// Get returns the verified developer token claims. +// +// +// Usage: +// j := jwt.New(...) +// app.Use(j.Verify(func() interface{} { return new(CustomClaims) })) +// app.Post("/restricted", func(ctx iris.Context){ +// claims := jwt.Get(ctx).(*CustomClaims) +// [use claims...] +// }) +// +// Note that there is one exception, if the value was a pointer +// to a map[string]interface{}, it returns the map itself so it can be +// accessible directly without the requirement of unwrapping it, e.g. +// j.Verify(func() interface{} { +// return &iris.Map{} +// } +// [...] +// claims := jwt.Get(ctx).(iris.Map) +func Get(ctx *context.Context) interface{} { + if tok := GetTokenInfo(ctx); tok != nil { + switch v := tok.Value.(type) { + case *context.Map: + return *v + default: + return 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 nil +} + +// GetTokenInfo returns the verified token's information. +func GetTokenInfo(ctx *context.Context) *TokenInfo { + if v := ctx.Values().Get(tokenInfoContextKey); v != nil { + if t, ok := v.(*TokenInfo); ok { + return t } } - return err + return nil } -// 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 +// Invalidate invalidates a verified JWT token. +// It adds the request token, retrieved by Verify methods, to the block list. +// Next request will be blocked, even if the token was not yet expired. +// This method can be used when the client-side does not clear the token +// on a user logout operation. +// +// Note: the Blocklist should be initialized before serve-time: j.UseBlocklist(). +func (j *JWT) Invalidate(ctx *context.Context) { + if j.Blocklist == nil { + ctx.Application().Logger().Debug("jwt.Invalidate: Blocklist is nil") + return + } - for _, extract := range j.Extractors { - if token = extract(ctx); token != "" { - break // ok we found it. - } + tokenInfo := GetTokenInfo(ctx) + if tokenInfo == nil { + return } - return j.VerifyTokenString(ctx, token, claimsPtr) + j.Blocklist.Set(tokenInfo.RequestToken, tokenInfo.Claims.Expiry.Time()) } -// 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 { +// VerifyTokenString verifies and unmarshals an extracted request token to "dest" destination. +// The last variadic input indicates any further validations against the verified token claims. +// If the given "dest" is a valid context.User then ctx.User() will return it. +// If the token is missing an `ErrMissing` is returned. +// If the incoming token was expired an `ErrExpired` is returned. +// If the incoming token was blocked by the server an `ErrBlocked` is returned. +func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interface{}, expectations ...Expectation) (*TokenInfo, error) { if token == "" { - return ErrMissing + return nil, ErrMissing } var ( @@ -494,7 +508,7 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, claimsPtr in if j.DecriptionKey != nil { t, cerr := jwt.ParseSignedAndEncrypted(token) if cerr != nil { - return cerr + return nil, cerr } parsedToken, err = t.Decrypt(j.DecriptionKey) @@ -502,112 +516,163 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, claimsPtr in parsedToken, err = jwt.ParseSigned(token) } if err != nil { - return err + return nil, err } - if err = parsedToken.Claims(j.VerificationKey, claimsPtr); err != nil { - return err + var claims Claims + if err = parsedToken.Claims(j.VerificationKey, dest, &claims); err != nil { + return nil, err } - return validateClaims(ctx, claimsPtr) -} + // Build the Expected value. + expected := Expected{} + for _, e := range expectations { + if e != nil { + // expection can be used as a field validation too (see MeetRequirements). + if err = e(&expected, dest); err != nil { + return nil, err + } + } + } -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" -) + // For other standard JWT claims fields such as "exp" + // The developer can just add a field of Expiry *NumericDate `json:"exp"` + // and will be filled by the parsed token automatically. + // No need for more interfaces. -// 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 + err = validateClaims(ctx, dest, claims, expected) + if err != nil { + if err == ErrExpired { + // If token was expired remove it from the block list. + if j.Blocklist != nil { + j.Blocklist.Del(token) + } + } + + return nil, err } - ctx.Values().Set(ClaimsContextKey, raw) - ctx.Next() -} + if j.Blocklist != nil { + // If token exists in the block list, then stop here. + if j.Blocklist.Has(token) { + return nil, ErrBlocked + } + } -// 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 + if ut, ok := dest.(TokenSetter); ok { + // The u.Token is empty even if we set it and export it on JSON structure. + // Set it manually. + ut.SetToken(token) } - raw, ok := v.(json.RawMessage) - if !ok { - return ErrMissing + // Set the information. + tokenInfo := &TokenInfo{ + RequestToken: token, + Claims: claims, + Value: dest, } - err := json.Unmarshal(raw, claimsPtr) + return tokenInfo, nil +} + +// TokenPair holds the access token and refresh token response. +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// TokenPair generates a token pair of access and refresh tokens. +// The first two arguments required for the refresh token +// and the last one is the claims for the access token one. +func (j *JWT) TokenPair(refreshMaxAge time.Duration, refreshClaims interface{}, accessClaims interface{}) (TokenPair, error) { + accessToken, err := j.Token(accessClaims) if err != nil { - return err + return TokenPair{}, 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) + refreshToken, err := j.token(refreshMaxAge, refreshClaims) + if err != nil { + return TokenPair{}, nil } - return nil + pair := TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + } + + return pair, nil } -// Get returns and validates (if not already) the claims -// stored on request context's values storage. +// Verify returns a middleware which +// decrypts an incoming request token to the result of the given "newPtr". +// It does write a 401 unauthorized status code if verification or decryption failed. +// It calls the `ctx.Next` on verified requests. // -// Should be used instead of the `ReadClaims` method when -// a custom verification middleware was registered (see the `Verify` method for an example). +// Iit unmarshals the token to the specific type returned from the given "newPtr" function. +// It sets the Context User and User's Token too. So the next handler(s) +// of the same chain can access the User through a `Context.User()` call. // -// 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 -// } +// Note unlike `VerifyToken`, this method automatically protects +// the claims with JSON required tags (see `MeetRequirements` Expection). // -// 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) +// On verified tokens: +// - The information can be retrieved through `Get` and `GetTokenInfo` functions. +// - User is set if the newPtr returns a valid Context User +// - The Context Logout method is set if Blocklist was initialized +// Any error is captured to the Context, +// which can be retrieved by a `ctx.GetErr()` call. +func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) context.Handler { + expections = append(expections, MeetRequirements(newPtr())) + + return func(ctx *context.Context) { + ptr := newPtr() + + tokenInfo, err := j.VerifyToken(ctx, ptr, expections...) if err != nil { - return nil, err + ctx.Application().Logger().Debugf("iris.jwt.Verify: %v", err) + ctx.StopWithError(401, context.PrivateError(err)) + return + } + + if u, ok := ptr.(context.User); ok { + ctx.SetUser(u) + } + + if j.Blocklist != nil { + ctx.SetLogoutFunc(j.Invalidate) } + + ctx.Values().Set(tokenInfoContextKey, tokenInfo) + ctx.Next() + } +} + +// NewUser returns a new User based on the given "opts". +// The caller can modify the User until its `GetToken` is called. +func (j *JWT) NewUser(opts ...UserOption) *User { + u := &User{ + j: j, + SimpleUser: &context.SimpleUser{ + Authorization: "IRIS_JWT_USER", // Used to separate a refresh token with a user/access one too. + Features: []context.UserFeature{ + context.TokenFeature, + }, + }, } - return claims, nil + for _, opt := range opts { + opt(u) + } + + return u +} + +// VerifyUser works like the `Verify` method but instead +// it unmarshals the token to the specific User type. +// It sets the Context User too. So the next handler(s) +// of the same chain can access the User through a `Context.User()` call. +func (j *JWT) VerifyUser() context.Handler { + return j.Verify(func() interface{} { + return new(User) + }) } diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index 0d30d6fd48..f42e80c1e9 100644 --- a/middleware/jwt/jwt_test.go +++ b/middleware/jwt/jwt_test.go @@ -12,11 +12,15 @@ import ( ) type userClaims struct { - jwt.Claims - Username string + // Optionally: + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience jwt.Audience `json:"aud"` + // + Username string `json:"username"` } -const testMaxAge = 3 * time.Second +const testMaxAge = 7 * time.Second // Random RSA verification and encryption. func TestRSA(t *testing.T) { @@ -25,13 +29,13 @@ func TestRSA(t *testing.T) { os.Remove(jwt.DefaultSignFilename) os.Remove(jwt.DefaultEncFilename) }) - testWriteVerifyToken(t, j) + testWriteVerifyBlockToken(t, j) } // HMAC verification and encryption. func TestHMAC(t *testing.T) { j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - testWriteVerifyToken(t, j) + testWriteVerifyBlockToken(t, j) } func TestNew_HMAC(t *testing.T) { @@ -44,7 +48,7 @@ func TestNew_HMAC(t *testing.T) { t.Fatal(err) } - testWriteVerifyToken(t, j) + testWriteVerifyBlockToken(t, j) } // HMAC verification only (unecrypted). @@ -53,53 +57,59 @@ func TestVerify(t *testing.T) { if err != nil { t.Fatal(err) } - testWriteVerifyToken(t, j) + testWriteVerifyBlockToken(t, j) } -func testWriteVerifyToken(t *testing.T, j *jwt.JWT) { +func testWriteVerifyBlockToken(t *testing.T, j *jwt.JWT) { t.Helper() + j.UseBlocklist() 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), + + customClaims := &userClaims{ + Issuer: "an-issuer", + Audience: jwt.Audience{"an-audience"}, + Subject: "user", Username: "kataras", } 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 + app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { + if err := ctx.GetErr(); err != nil { + // Test accessing the private error and set this as the response body. + ctx.WriteString(err.Error()) + } else { // Else the default behavior + ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) } + }) - ctx.JSON(claims) + app.Get("/auth", func(ctx iris.Context) { + j.WriteToken(ctx, customClaims) }) - app.Post("/restricted_middleware_readclaims", j.Verify, func(ctx iris.Context) { + app.Post("/protected", func(ctx iris.Context) { var claims userClaims - if err := jwt.ReadClaims(ctx, &claims); err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) + _, err := j.VerifyToken(ctx, &claims) + if err != nil { + // t.Logf("%s: %v", ctx.Path(), err) + ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) return } ctx.JSON(claims) }) - app.Post("/restricted_middleware_get", j.Verify, func(ctx iris.Context) { - claims, err := jwt.Get(ctx) - if err != nil { - ctx.StopWithStatus(iris.StatusUnauthorized) - return - } - + m := app.Party("/middleware") + m.Use(j.Verify(func() interface{} { + return new(userClaims) + })) + m.Post("/protected", func(ctx iris.Context) { + claims := jwt.Get(ctx) ctx.JSON(claims) }) + m.Post("/invalidate", func(ctx iris.Context) { + ctx.Logout() // OR j.Invalidate(ctx) + }) e := httptest.New(t, app) @@ -109,31 +119,186 @@ func testWriteVerifyToken(t *testing.T, j *jwt.JWT) { t.Fatalf("empty token") } - restrictedPaths := [...]string{"/restricted", "/restricted_middleware_readclaims", "/restricted_middleware_get"} + restrictedPaths := [...]string{"/protected", "/middleware/protected"} now := time.Now() for _, path := range restrictedPaths { // Authorization Header. e.POST(path).WithHeader("Authorization", "Bearer "+rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) + Status(httptest.StatusOK).JSON().Equal(customClaims) // URL Query. e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) + Status(httptest.StatusOK).JSON().Equal(customClaims) // JSON Body. e.POST(path).WithJSON(iris.Map{"access_token": rawToken}).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) + Status(httptest.StatusOK).JSON().Equal(customClaims) // Missing "Bearer". e.POST(path).WithHeader("Authorization", rawToken).Expect(). - Status(httptest.StatusUnauthorized) + Status(httptest.StatusUnauthorized).Body().Equal("token is missing") } + + // Invalidate the token. + e.POST("/middleware/invalidate").WithQuery("token", rawToken).Expect(). + Status(httptest.StatusOK) + // Token is blocked by server. + e.POST("/middleware/protected").WithQuery("token", rawToken).Expect(). + Status(httptest.StatusUnauthorized).Body().Equal("token is blocked") + expireRemDur := testMaxAge - time.Since(now) // Expiration. time.Sleep(expireRemDur /* -end */) for _, path := range restrictedPaths { - e.POST(path).WithQuery("token", rawToken).Expect().Status(httptest.StatusUnauthorized) + e.POST(path).WithQuery("token", rawToken).Expect(). + Status(httptest.StatusUnauthorized).Body().Equal("token is expired (exp)") + } +} + +func TestVerifyMap(t *testing.T) { + j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") + expectedClaims := iris.Map{ + "iss": "tester", + "username": "makis", + "roles": []string{"admin"}, + } + + app := iris.New() + app.Get("/user/auth", func(ctx iris.Context) { + err := j.WriteToken(ctx, expectedClaims) + if err != nil { + ctx.StopWithError(iris.StatusUnauthorized, err) + return + } + + if expectedClaims["exp"] == nil || expectedClaims["iat"] == nil { + ctx.StopWithText(iris.StatusBadRequest, + "exp or/and iat is nil - this means that the expiry was not set") + return + } + }) + + userAPI := app.Party("/user") + userAPI.Post("/", func(ctx iris.Context) { + var claims iris.Map + if _, err := j.VerifyToken(ctx, &claims); err != nil { + ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) + return + } + + ctx.JSON(claims) + }) + + // Test map + Verify middleware. + userAPI.Post("/middleware", j.Verify(func() interface{} { + return &iris.Map{} // or &map[string]interface{}{} + }), func(ctx iris.Context) { + claims := jwt.Get(ctx) + ctx.JSON(claims) + }) + + e := httptest.New(t, app, httptest.LogLevel("error")) + token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() + if token == "" { + t.Fatalf("empty token") } + + e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). + Status(httptest.StatusOK).JSON().Equal(expectedClaims) + + e.POST("/user/middleware").WithHeader("Authorization", "Bearer "+token).Expect(). + Status(httptest.StatusOK).JSON().Equal(expectedClaims) + + e.POST("/user").Expect().Status(httptest.StatusUnauthorized) +} + +type customClaims struct { + Username string `json:"username"` + Token string `json:"token"` +} + +func (c *customClaims) SetToken(tok string) { + c.Token = tok +} + +func TestVerifyStruct(t *testing.T) { + maxAge := testMaxAge / 2 + j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") + + app := iris.New() + app.Get("/user/auth", func(ctx iris.Context) { + err := j.WriteToken(ctx, customClaims{ + Username: "makis", + }) + if err != nil { + ctx.StopWithError(iris.StatusUnauthorized, err) + return + } + }) + + userAPI := app.Party("/user") + userAPI.Post("/", func(ctx iris.Context) { + var claims customClaims + if _, err := j.VerifyToken(ctx, &claims); err != nil { + ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) + return + } + + ctx.JSON(claims) + }) + + e := httptest.New(t, app) + token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() + if token == "" { + t.Fatalf("empty token") + } + e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). + Status(httptest.StatusOK).JSON().Object().ContainsMap(iris.Map{ + "username": "makis", + "token": token, // Test SetToken. + }) + + e.POST("/user").Expect().Status(httptest.StatusUnauthorized) + time.Sleep(maxAge) + e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect().Status(httptest.StatusUnauthorized) +} + +func TestVerifyUserAndExpected(t *testing.T) { // Tests the jwt.User struct + context validator + expected. + maxAge := testMaxAge / 2 + j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") + expectedUser := j.NewUser(jwt.Username("makis"), jwt.Roles("admin"), jwt.Fields(iris.Map{ + "custom": true, + })) // only for the sake of the test, we iniitalize it here. + expectedUser.Issuer = "tester" + + app := iris.New() + app.Get("/user/auth", func(ctx iris.Context) { + tok, err := expectedUser.GetToken() + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + ctx.WriteString(tok) + }) + + userAPI := app.Party("/user") + userAPI.Use(jwt.WithExpected(jwt.Expected{Issuer: "tester"}, j.VerifyUser())) + userAPI.Post("/", func(ctx iris.Context) { + user := ctx.User() + ctx.JSON(user) + }) + + e := httptest.New(t, app) + token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() + if token == "" { + t.Fatalf("empty token") + } + + e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). + Status(httptest.StatusOK).JSON().Equal(expectedUser) + + // Test generic client message if we don't manage the private error by ourselves. + e.POST("/user").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") } diff --git a/middleware/jwt/user.go b/middleware/jwt/user.go new file mode 100644 index 0000000000..3d8af27f27 --- /dev/null +++ b/middleware/jwt/user.go @@ -0,0 +1,187 @@ +package jwt + +import ( + "time" + + "github.com/kataras/iris/v12/context" +) + +// User a common User structure for JWT. +// However, we're not limited to that one; +// any Go structure can be generated as a JWT token. +// +// Look `NewUser` and `VerifyUser` JWT middleware's methods. +// Use its `GetToken` method to generate the token when +// the User structure is set. +type User struct { + Claims + // Note: we could use a map too as the Token is generated when GetToken is called. + *context.SimpleUser + + j *JWT +} + +var ( + _ context.FeaturedUser = (*User)(nil) + _ TokenSetter = (*User)(nil) + _ ContextValidator = (*User)(nil) +) + +// UserOption sets optional fields for a new User +// See `NewUser` instance function. +type UserOption func(*User) + +// Username sets the Username and the JWT Claim's Subject +// to the given "username". +func Username(username string) UserOption { + return func(u *User) { + u.Username = username + u.Claims.Subject = username + u.Features = append(u.Features, context.UsernameFeature) + } +} + +// Email sets the Email field for the User field. +func Email(email string) UserOption { + return func(u *User) { + u.Email = email + u.Features = append(u.Features, context.EmailFeature) + } +} + +// Roles upserts to the User's Roles field. +func Roles(roles ...string) UserOption { + return func(u *User) { + u.Roles = roles + u.Features = append(u.Features, context.RolesFeature) + } +} + +// MaxAge sets claims expiration and the AuthorizedAt User field. +func MaxAge(maxAge time.Duration) UserOption { + return func(u *User) { + now := time.Now() + u.Claims.Expiry = NewNumericDate(now.Add(maxAge)) + u.Claims.IssuedAt = NewNumericDate(now) + u.AuthorizedAt = now + + u.Features = append(u.Features, context.AuthorizedAtFeature) + } +} + +// Fields copies the "fields" to the user's Fields field. +// This can be used to set custom fields to the User instance. +func Fields(fields context.Map) UserOption { + return func(u *User) { + if len(fields) == 0 { + return + } + + if u.Fields == nil { + u.Fields = make(context.Map, len(fields)) + } + + for k, v := range fields { + u.Fields[k] = v + } + + u.Features = append(u.Features, context.FieldsFeature) + } +} + +// SetToken is called automaticaly on VerifyUser/VerifyObject. +// It sets the extracted from request, and verified from server raw token. +func (u *User) SetToken(token string) { + u.Token = token +} + +// GetToken overrides the SimpleUser's Token +// and returns the jwt generated token, among with +// a generator error, if any. +func (u *User) GetToken() (string, error) { + if u.Token != "" { + return u.Token, nil + } + + if u.j != nil { // it's always not nil. + if u.j.MaxAge > 0 { + // if the MaxAge option was not manually set, resolve it from the JWT instance. + MaxAge(u.j.MaxAge)(u) + } + + // we could generate a token here + // but let's do it on GetToken + // as the user fields may change + // by the caller manually until the token + // sent to the client. + tok, err := u.j.Token(u) + if err != nil { + return "", err + } + + u.Token = tok + } + + if u.Token == "" { + return "", ErrMissing + } + + return u.Token, nil +} + +// Validate validates the current user's claims against +// the request. It's called automatically by the JWT instance. +func (u *User) Validate(ctx *context.Context, claims Claims, e Expected) error { + err := u.Claims.ValidateWithLeeway(e, 0) + if err != nil { + return err + } + + if u.SimpleUser.Authorization != "IRIS_JWT_USER" { + return ErrInvalidKey + } + + // We could add specific User Expectations (new struct and accept an interface{}), + // but for the sake of code simplicity we don't, unless is requested, as the caller + // can validate specific fields by its own at the next step. + return nil +} + +// UnmarshalJSON implements the json unmarshaler interface. +func (u *User) UnmarshalJSON(data []byte) error { + err := Unmarshal(data, &u.Claims) + if err != nil { + return err + } + simpleUser := new(context.SimpleUser) + err = Unmarshal(data, simpleUser) + if err != nil { + return err + } + u.SimpleUser = simpleUser + return nil +} + +// MarshalJSON implements the json marshaler interface. +func (u *User) MarshalJSON() ([]byte, error) { + claimsB, err := Marshal(u.Claims) + if err != nil { + return nil, err + } + + userB, err := Marshal(u.SimpleUser) + if err != nil { + return nil, err + } + + if len(userB) == 0 { + return claimsB, nil + } + + claimsB = claimsB[0 : len(claimsB)-1] // remove last '}' + userB = userB[1:] // remove first '{' + + raw := append(claimsB, ',') + raw = append(raw, userB...) + return raw, nil +} diff --git a/middleware/jwt/validation.go b/middleware/jwt/validation.go new file mode 100644 index 0000000000..9e8ffd62c7 --- /dev/null +++ b/middleware/jwt/validation.go @@ -0,0 +1,212 @@ +package jwt + +import ( + "bytes" + "errors" + "reflect" + "strings" + "time" + + "github.com/kataras/iris/v12/context" + + "github.com/square/go-jose/v3/json" + // Use this package instead of the standard encoding/json + // to marshal the NumericDate as expected by the implementation (see 'normalize`). + "github.com/square/go-jose/v3/jwt" +) + +const ( + claimsExpectedContextKey = "iris.jwt.claims.expected" + needsValidationContextKey = "iris.jwt.claims.unvalidated" +) + +var ( + // ErrMissing when token cannot be extracted from the request. + ErrMissing = errors.New("token is missing") + // ErrMissingKey when token does not contain a required JSON field. + ErrMissingKey = errors.New("token is missing a required field") + // ErrExpired indicates that token is used after expiry time indicated in exp claim. + ErrExpired = errors.New("token is expired (exp)") + // ErrNotValidYet indicates that token is used before time indicated in nbf claim. + ErrNotValidYet = errors.New("token not valid yet (nbf)") + // ErrIssuedInTheFuture indicates that the iat field is in the future. + ErrIssuedInTheFuture = errors.New("token issued in the future (iat)") + // ErrBlocked indicates that the token was not yet expired + // but was blocked by the server's Blocklist. + ErrBlocked = errors.New("token is blocked") +) + +// Expectation option to provide +// an extra layer of token validation, a claims type protection. +// See `VerifyToken` method. +type Expectation func(e *Expected, claims interface{}) error + +// Expect protects the claims with the expected values. +func Expect(expected Expected) Expectation { + return func(e *Expected, _ interface{}) error { + *e = expected + return nil + } +} + +// ExpectID protects the claims with an ID validation. +func ExpectID(id string) Expectation { + return func(e *Expected, _ interface{}) error { + e.ID = id + return nil + } +} + +// ExpectIssuer protects the claims with an issuer validation. +func ExpectIssuer(issuer string) Expectation { + return func(e *Expected, _ interface{}) error { + e.Issuer = issuer + return nil + } +} + +// ExpectSubject protects the claims with a subject validation. +func ExpectSubject(sub string) Expectation { + return func(e *Expected, _ interface{}) error { + e.Subject = sub + return nil + } +} + +// ExpectAudience protects the claims with an audience validation. +func ExpectAudience(audience ...string) Expectation { + return func(e *Expected, _ interface{}) error { + e.Audience = audience + return nil + } +} + +// MeetRequirements protects the custom fields of JWT claims +// based on the json:required tag; `json:"name,required"`. +// It accepts the value type. +// +// Usage: +// Verify/VerifyToken(... MeetRequirements(MyUser{})) +func MeetRequirements(claimsType interface{}) Expectation { + // pre-calculate if we need to use reflection at serve time to check for required fields, + // this can work as an alternative of expections for custom non-standard JWT fields. + requireFieldsIndexes := getRequiredFieldIndexes(claimsType) + + return func(e *Expected, claims interface{}) error { + if len(requireFieldsIndexes) > 0 { + val := reflect.Indirect(reflect.ValueOf(claims)) + for _, idx := range requireFieldsIndexes { + field := val.Field(idx) + if field.IsZero() { + return ErrMissingKey + } + } + } + + return nil + } +} + +// WithExpected is a middleware wrapper. It wraps a VerifyXXX middleware +// with expected claims fields protection. +// Usage: +// jwt.WithExpected(jwt.Expected{Issuer:"app"}, j.VerifyUser) +func WithExpected(e Expected, verifyHandler context.Handler) context.Handler { + return func(ctx *context.Context) { + ctx.Values().Set(claimsExpectedContextKey, e) + verifyHandler(ctx) + } +} + +// ContextValidator validates the object based on the given +// claims and the expected once. The end-developer +// can use this method for advanced validations based on the request Context. +type ContextValidator interface { + Validate(ctx *context.Context, claims Claims, e Expected) error +} + +func validateClaims(ctx *context.Context, dest interface{}, claims Claims, expected Expected) (err error) { + // Get any dynamic expectation set by prior middleware. + // See `WithExpected` middleware. + if v := ctx.Values().Get(claimsExpectedContextKey); v != nil { + if e, ok := v.(Expected); ok { + expected = e + } + } + // Force-set the time, it's important for expiration. + expected.Time = time.Now() + switch c := dest.(type) { + case Claims: + err = c.ValidateWithLeeway(expected, 0) + case ContextValidator: + err = c.Validate(ctx, claims, expected) + case *context.Map: + // if the dest is a map then set automatically the expiration settings here, + // so the caller can work further with it. + err = claims.ValidateWithLeeway(expected, 0) + if err == nil { + (*c)["exp"] = claims.Expiry + (*c)["iat"] = claims.IssuedAt + if claims.NotBefore != nil { + (*c)["nbf"] = claims.NotBefore + } + } + default: + err = claims.ValidateWithLeeway(expected, 0) + } + + if err != nil { + switch err { + case jwt.ErrExpired: + return ErrExpired + case jwt.ErrNotValidYet: + return ErrNotValidYet + case jwt.ErrIssuedInTheFuture: + return ErrIssuedInTheFuture + } + } + + return err +} + +func normalize(i interface{}) (context.Map, error) { + if m, ok := i.(context.Map); ok { + return m, nil + } + + m := make(context.Map) + + raw, err := json.Marshal(i) + if err != nil { + return nil, err + } + + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + + if err := d.Decode(&m); err != nil { + return nil, err + } + + return m, nil +} + +func getRequiredFieldIndexes(i interface{}) (v []int) { + val := reflect.Indirect(reflect.ValueOf(i)) + typ := val.Type() + if typ.Kind() != reflect.Struct { + return nil + } + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + // Note: for the sake of simplicity we don't lookup for nested objects (FieldByIndex), + // we could do that as we do in dependency injection feature but unless requirested we don't. + tag := field.Tag.Get("json") + if strings.Contains(tag, ",required") { + v = append(v, i) + } + } + + return +} From a412ee55ae812be1e97c61c506309d13c1f5bda0 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 17 Oct 2020 15:22:42 +0300 Subject: [PATCH 02/19] jwt: add VerifyJSON and ReadJSON helpers --- middleware/jwt/jwt.go | 57 ++++++++++++++++++++++++++++++++++++++ middleware/jwt/jwt_test.go | 36 ++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 37df46223f..6cc8c16742 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -2,6 +2,7 @@ package jwt import ( "crypto" + "encoding/json" "os" "strings" "time" @@ -449,6 +450,17 @@ func Get(ctx *context.Context) interface{} { switch v := tok.Value.(type) { case *context.Map: return *v + case *json.RawMessage: + // This is useful when we can accept more than one + // type of JWT token in the same request path, + // but we also want to keep type safety. + // Usage: + // type myClaims struct { Roles []string `json:"roles"`} + // v := jwt.Get(ctx) + // var claims myClaims + // jwt.Unmarshal(v, &claims) + // [...claims.Roles] + return *v default: return v } @@ -621,7 +633,19 @@ func (j *JWT) TokenPair(refreshMaxAge time.Duration, refreshClaims interface{}, // - The Context Logout method is set if Blocklist was initialized // Any error is captured to the Context, // which can be retrieved by a `ctx.GetErr()` call. +// +// See `VerifyJSON` too. func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) context.Handler { + if newPtr == nil { + newPtr = func() interface{} { + // Return a map here as the default type one, + // as it does allow .Get callers to access its fields with ease + // (although, I always recommend using structs for type-safety and + // also they can accept a required tag option too). + return &context.Map{} + } + } + expections = append(expections, MeetRequirements(newPtr())) return func(ctx *context.Context) { @@ -647,6 +671,39 @@ func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) conte } } +// VerifyJSON works like `Verify` but instead it +// binds its "newPtr" function to return a raw JSON message. +// This allows the caller to bind this JSON message to any Go structure (or map). +// This is useful when we can accept more than one +// type of JWT token in the same request path, +// but we also want to keep type safety. +// Usage: +// app.Use(jwt.VerifyJSON()) +// Inside a route Handler: +// claims := struct { Roles []string `json:"roles"`}{} +// jwt.ReadJSON(ctx, &claims) +// ...access to claims.Roles as []string +func (j *JWT) VerifyJSON(expections ...Expectation) context.Handler { + return j.Verify(func() interface{} { + return new(json.RawMessage) + }) +} + +// ReadJSON is a helper which binds "claimsPtr" to the +// raw JSON token claims. +// Use inside the handlers when `VerifyJSON()` middleware was registered. +func ReadJSON(ctx *context.Context, claimsPtr interface{}) error { + v := Get(ctx) + if v == nil { + return ErrMissing + } + data, ok := v.(json.RawMessage) + if !ok { + return ErrMissing + } + return Unmarshal(data, claimsPtr) +} + // NewUser returns a new User based on the given "opts". // The caller can modify the User until its `GetToken` is called. func (j *JWT) NewUser(opts ...UserOption) *User { diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index f42e80c1e9..b3e8c42262 100644 --- a/middleware/jwt/jwt_test.go +++ b/middleware/jwt/jwt_test.go @@ -192,9 +192,7 @@ func TestVerifyMap(t *testing.T) { }) // Test map + Verify middleware. - userAPI.Post("/middleware", j.Verify(func() interface{} { - return &iris.Map{} // or &map[string]interface{}{} - }), func(ctx iris.Context) { + userAPI.Post("/middleware", j.Verify(nil), func(ctx iris.Context) { claims := jwt.Get(ctx) ctx.JSON(claims) }) @@ -265,6 +263,38 @@ func TestVerifyStruct(t *testing.T) { e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect().Status(httptest.StatusUnauthorized) } +func TestVerifyJSON(t *testing.T) { + j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") + + app := iris.New() + app.Get("/user/auth", func(ctx iris.Context) { + err := j.WriteToken(ctx, iris.Map{"roles": []string{"admin"}}) + if err != nil { + ctx.StopWithError(iris.StatusUnauthorized, err) + return + } + }) + + app.Post("/", j.VerifyJSON(), func(ctx iris.Context) { + claims := struct { + Roles []string `json:"roles"` + }{} + jwt.ReadJSON(ctx, &claims) + ctx.JSON(claims) + }) + + e := httptest.New(t, app, httptest.LogLevel("error")) + token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() + if token == "" { + t.Fatalf("empty token") + } + + e.POST("/").WithHeader("Authorization", "Bearer "+token).Expect(). + Status(httptest.StatusOK).JSON().Equal(iris.Map{"roles": []string{"admin"}}) + + e.POST("/").Expect().Status(httptest.StatusUnauthorized) +} + func TestVerifyUserAndExpected(t *testing.T) { // Tests the jwt.User struct + context validator + expected. maxAge := testMaxAge / 2 j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") From 09923183e815a3079c2a0a53fdeca312f9c02aa2 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 14:42:19 +0300 Subject: [PATCH 03/19] add an extra security layer on JWT and able to separate access from refresh tokens without any end-developer action on the claims payload (e.g. set a different issuer) --- _examples/auth/jwt/refresh-token/main.go | 46 +++++++++++++-------- middleware/jwt/jwt.go | 52 ++++++++++++++++++++++-- middleware/jwt/validation.go | 52 ++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index 4f41c22d03..7c0629b537 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -9,11 +9,16 @@ import ( // UserClaims a custom access claims structure. type UserClaims struct { - // We could that JWT field to separate the access and refresh token: - // Issuer string `json:"iss"` - // But let's cover the "required" feature too, see below: - ID string `json:"user_id,required"` - Username string `json:"username,required"` + // In order to separate refresh and access tokens on validation level: + // - Set a different Issuer, with a field of: Issuer string `json:"iss"` + // - Set the Iris JWT's json tag option "required" on an access token field, + // e.g. Username string `json:"username,required"` + // - Let the middleware validate the correct one based on the given MaxAge, + // which should be different between refresh and max age (refersh should be bigger) + // by setting the `jwt.ExpectRefreshToken` on Verify/VerifyToken/VerifyTokenString + // (see `refreshToken` function below) + ID string `json:"user_id"` + Username string `json:"username"` } // For refresh token, we will just use the jwt.Claims @@ -28,8 +33,8 @@ func main() { generateTokenPair(ctx, j) }) - app.Get("/refresh_json", func(ctx iris.Context) { - refreshTokenFromJSON(ctx, j) + app.Get("/refresh", func(ctx iris.Context) { + refreshToken(ctx, j) }) protectedAPI := app.Party("/protected") @@ -54,7 +59,9 @@ func main() { // http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token}) // http://localhost:8080/protected?token={access_token} (200) // http://localhost:8080/protected?token={refresh_token} (401) - // http://localhost:8080/refresh_json (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // http://localhost:8080/refresh?token={refresh_token} + // OR (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // http://localhost:8080/refresh?token={access_token} (401) app.Listen(":8080") } @@ -87,20 +94,25 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) { ctx.JSON(tokenPair) } -func refreshTokenFromJSON(ctx iris.Context, j *jwt.JWT) { +func refreshToken(ctx iris.Context, j *jwt.JWT) { var tokenPair jwt.TokenPair - // Grab the refresh token from a JSON body (you can let it fetch by URL parameter too but - // it's common practice that you read it from a json body as - // it may contain the access token too (the same response we sent on generateTokenPair)). - err := ctx.ReadJSON(&tokenPair) - if err != nil { - ctx.StatusCode(iris.StatusBadRequest) - return + if token := ctx.URLParam("token"); token != "" { + // Grab the refresh token from the url argument. + tokenPair.RefreshToken = token + } else { + // Otherwise grab the refresh token from a JSON body (you can let it fetch by URL parameter too but + // it's common practice that you read it from a json body as + // it may contain the access token too (the same response we sent on generateTokenPair)). + err := ctx.ReadJSON(&tokenPair) + if err != nil { + ctx.StatusCode(iris.StatusBadRequest) + return + } } var refreshClaims jwt.Claims - err = j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims) + _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) if err != nil { ctx.Application().Logger().Debugf("verify refresh token: %v", err) ctx.StatusCode(iris.StatusUnauthorized) diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 6cc8c16742..25154bd308 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -3,6 +3,7 @@ package jwt import ( "crypto" "encoding/json" + "fmt" "os" "strings" "time" @@ -338,7 +339,6 @@ func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { return "", nErr } - // Set expiration, if missing. ExpiryMap(maxAge, c) var ( @@ -531,22 +531,45 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interfa return nil, err } - var claims Claims - if err = parsedToken.Claims(j.VerificationKey, dest, &claims); err != nil { + var ( + claims Claims + tokenMaxAger tokenWithMaxAge + ) + + if err = parsedToken.Claims(j.VerificationKey, dest, &claims, &tokenMaxAger); err != nil { return nil, err } + expectMaxAge := j.MaxAge + // Build the Expected value. expected := Expected{} for _, e := range expectations { if e != nil { // expection can be used as a field validation too (see MeetRequirements). if err = e(&expected, dest); err != nil { + if err == ErrExpectRefreshToken { + if tokenMaxAger.MaxAge > 0 { + // If max age exists, grab it and compare it later. + // Otherwise fire the ErrExpectRefreshToken. + expectMaxAge = tokenMaxAger.MaxAge + continue + } + } return nil, err } } } + gotMaxAge := getMaxAge(claims) + if !compareMaxAge(expectMaxAge, gotMaxAge) { + // Additional check to automatically invalidate + // any previous jwt maxAge setting change. + // In-short, if the time.Now().Add j.MaxAge + // does not match the "iat" (issued at) then we invalidate the token. + return nil, ErrInvalidMaxAge + } + // For other standard JWT claims fields such as "exp" // The developer can just add a field of Expiry *NumericDate `json:"exp"` // and will be filled by the parsed token automatically. @@ -593,16 +616,37 @@ type TokenPair struct { RefreshToken string `json:"refresh_token"` } +type tokenWithMaxAge struct { + // Useful to separate access from refresh tokens. + // Can be used to by-pass the internal check of expected + // MaxAge setting to match the token's received max age too. + MaxAge time.Duration `json:"tokenMaxAge"` +} + // TokenPair generates a token pair of access and refresh tokens. // The first two arguments required for the refresh token // and the last one is the claims for the access token one. func (j *JWT) TokenPair(refreshMaxAge time.Duration, refreshClaims interface{}, accessClaims interface{}) (TokenPair, error) { + if refreshMaxAge <= j.MaxAge { + return TokenPair{}, fmt.Errorf("refresh max age should be bigger than access token's one[%d - %d]", refreshMaxAge, j.MaxAge) + } + accessToken, err := j.Token(accessClaims) if err != nil { return TokenPair{}, err } - refreshToken, err := j.token(refreshMaxAge, refreshClaims) + c, err := normalize(refreshClaims) + if err != nil { + return TokenPair{}, err + } + if c == nil { + c = make(context.Map) + } + // need to validate against its value instead of the setting's one (see `VerifyTokenString`). + c["tokenMaxAge"] = refreshMaxAge + + refreshToken, err := j.token(refreshMaxAge, c) if err != nil { return TokenPair{}, nil } diff --git a/middleware/jwt/validation.go b/middleware/jwt/validation.go index 9e8ffd62c7..9b5b69642f 100644 --- a/middleware/jwt/validation.go +++ b/middleware/jwt/validation.go @@ -21,9 +21,9 @@ const ( ) var ( - // ErrMissing when token cannot be extracted from the request. + // ErrMissing when token cannot be extracted from the request (custm error). ErrMissing = errors.New("token is missing") - // ErrMissingKey when token does not contain a required JSON field. + // ErrMissingKey when token does not contain a required JSON field (custom error). ErrMissingKey = errors.New("token is missing a required field") // ErrExpired indicates that token is used after expiry time indicated in exp claim. ErrExpired = errors.New("token is expired (exp)") @@ -32,8 +32,14 @@ var ( // ErrIssuedInTheFuture indicates that the iat field is in the future. ErrIssuedInTheFuture = errors.New("token issued in the future (iat)") // ErrBlocked indicates that the token was not yet expired - // but was blocked by the server's Blocklist. + // but was blocked by the server's Blocklist (custom error). ErrBlocked = errors.New("token is blocked") + // ErrInvalidMaxAge indicates that the token is using a different + // max age than the configurated one ( custom error). + ErrInvalidMaxAge = errors.New("token contains invalid max age") + // ErrExpectRefreshToken indicates that the retrieved token + // was not a refresh token one when `ExpectRefreshToken` is set (custome rror). + ErrExpectRefreshToken = errors.New("expect refresh token") ) // Expectation option to provide @@ -81,6 +87,16 @@ func ExpectAudience(audience ...string) Expectation { } } +// ExpectRefreshToken SHOULD be passed when a token should be verified +// based on the expiration set by `TokenPair` method instead of the JWT instance's MaxAge setting. +// Useful to validate Refresh Tokens and invalidate Access ones when refresh API is fired, +// if that option is missing then refresh tokens are invalidated when an access token was expected. +// +// Usage: +// var refreshClaims jwt.Claims +// _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) +func ExpectRefreshToken(e *Expected, _ interface{}) error { return ErrExpectRefreshToken } + // MeetRequirements protects the custom fields of JWT claims // based on the json:required tag; `json:"name,required"`. // It accepts the value type. @@ -210,3 +226,33 @@ func getRequiredFieldIndexes(i interface{}) (v []int) { return } + +// getMaxAge returns the result of expiry-issued at. +// Note that if in JWT MaxAge's was set to a value like: 3.5 seconds +// this will return 3 on token retreival. Of course this is not a problem +// in real world apps as they don't invalidate tokens in seconds +// based on a division result like 2/7. +func getMaxAge(claims Claims) time.Duration { + if issuedAt := claims.IssuedAt.Time(); !issuedAt.IsZero() { + gotMaxAge := claims.Expiry.Time().Sub(issuedAt) + return gotMaxAge + } + + return 0 +} + +func compareMaxAge(expected, got time.Duration) bool { + if expected == got { + return true + } + + // got is int64, maybe rounded, but the max age setting is precise, may be a float result + // e.g. the result of a division 2/7=3.5, + // try to validate by round of second so similar/or equal max age setting are considered valid. + min, max := expected-time.Second, expected+time.Second + if got < min || got > max { + return false + } + + return true +} From 0d73b63b288c4745437435ade4353a0dae2a59c7 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 17:15:29 +0300 Subject: [PATCH 04/19] jwt: add the (last) helper: VerifyRefreshToken --- _examples/auth/jwt/refresh-token/main.go | 48 ++++++++---------- context/context.go | 21 ++++++++ middleware/jwt/jwt.go | 62 ++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 33 deletions(-) diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index 7c0629b537..f48c8231b5 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -60,7 +60,8 @@ func main() { // http://localhost:8080/protected?token={access_token} (200) // http://localhost:8080/protected?token={refresh_token} (401) // http://localhost:8080/refresh?token={refresh_token} - // OR (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // OR http://localhost:8080/refresh (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) + // OR http://localhost:8080/refresh (request PLAIN TEXT of {refresh_token}) (200) (response JSON {access_token, refresh_token}) // http://localhost:8080/refresh?token={access_token} (401) app.Listen(":8080") } @@ -95,45 +96,36 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) { } func refreshToken(ctx iris.Context, j *jwt.JWT) { - var tokenPair jwt.TokenPair - - if token := ctx.URLParam("token"); token != "" { - // Grab the refresh token from the url argument. - tokenPair.RefreshToken = token - } else { - // Otherwise grab the refresh token from a JSON body (you can let it fetch by URL parameter too but - // it's common practice that you read it from a json body as - // it may contain the access token too (the same response we sent on generateTokenPair)). - err := ctx.ReadJSON(&tokenPair) - if err != nil { - ctx.StatusCode(iris.StatusBadRequest) - return - } - } + /* + We could pass a jwt.Claims pointer as the second argument, + but we don't have to because the method already returns + the standard JWT claims information back to us: + refresh, err := VerifyRefreshToken(ctx, nil) + */ + + // 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" - var refreshClaims jwt.Claims - _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) + // Verify the refresh token, which its subject MUST match the "currentUserID". + _, err := j.VerifyRefreshToken(ctx, nil, jwt.ExpectSubject(currentUserID)) if err != nil { ctx.Application().Logger().Debugf("verify refresh token: %v", err) ctx.StatusCode(iris.StatusUnauthorized) return } - // 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. + /* Custom validation checks can be performed after Verify calls too: currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" - - userID := refreshClaims.Subject + userID := refresh.Claims.Subject if userID != currentUserID { ctx.StopWithStatus(iris.StatusUnauthorized) return } - // - // Otherwise, the request must contain the (old) access token too, - // even if it's invalid, we can still fetch its fields, such as the user id. - // [...leave it for you] + */ // All OK, re-generate the new pair and send to client. generateTokenPair(ctx, j) diff --git a/context/context.go b/context/context.go index aa15cbac0d..f4352ba8f2 100644 --- a/context/context.go +++ b/context/context.go @@ -2410,7 +2410,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. diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 25154bd308..f449383e82 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -395,6 +395,32 @@ func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}, expectati return j.VerifyTokenString(ctx, token, claimsPtr, expectations...) } +// VerifyRefreshToken like the `VerifyToken` but it verifies a refresh token one instead. +// If the implementation does not fill the application's requirements, +// you can ignore this method and still use the `VerifyToken` for refresh tokens too. +// +// This method adds the ExpectRefreshToken expectation and it +// tries to read the refresh token from raw body or, +// if content type was application/json, then it extracts the token +// from the JSON request body's {"refresh_token": "$token"} key. +func (j *JWT) VerifyRefreshToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { + token := j.RequestToken(ctx) + if token == "" { + var tokenPair TokenPair // read "refresh_token" from JSON. + if ctx.GetContentTypeRequested() == context.ContentJSONHeaderValue { + ctx.ReadJSON(&tokenPair) // ignore error. + token = tokenPair.RefreshToken + if token == "" { + return nil, ErrMissing + } + } else { + ctx.ReadBody(&token) + } + } + + return j.VerifyTokenString(ctx, token, claimsPtr, append(expectations, ExpectRefreshToken)...) +} + // RequestToken extracts the token from the request. func (j *JWT) RequestToken(ctx *context.Context) (token string) { for _, extract := range j.Extractors { @@ -536,10 +562,34 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interfa tokenMaxAger tokenWithMaxAge ) - if err = parsedToken.Claims(j.VerificationKey, dest, &claims, &tokenMaxAger); err != nil { + var ( + ignoreDest = dest == nil + ignoreVarClaims bool + ) + if !ignoreDest { // if dest was not nil, check if the dest is already a standard claims pointer. + _, ignoreVarClaims = dest.(*Claims) + } + + // Ensure read the standard claims one if dest was Claims or was nil. + // (it wont break anything if we unmarshal them twice though, we just do it for performance reasons). + var pointers = []interface{}{&tokenMaxAger} + if !ignoreDest { + pointers = append(pointers, dest) + } + if !ignoreVarClaims { + pointers = append(pointers, &claims) + } + if err = parsedToken.Claims(j.VerificationKey, pointers...); err != nil { return nil, err } + // Set the std claims, if missing from receiver so the expectations and validation still work. + if ignoreVarClaims { + claims = *dest.(*Claims) + } else if ignoreDest { + dest = &claims + } + expectMaxAge := j.MaxAge // Build the Expected value. @@ -594,10 +644,12 @@ func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interfa } } - if ut, ok := dest.(TokenSetter); ok { - // The u.Token is empty even if we set it and export it on JSON structure. - // Set it manually. - ut.SetToken(token) + if !ignoreDest { + if ut, ok := dest.(TokenSetter); ok { + // The u.Token is empty even if we set it and export it on JSON structure. + // Set it manually. + ut.SetToken(token) + } } // Set the information. From 7ab51805baa57fe68af6ce2c79eef03f7e754570 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 19:37:33 +0300 Subject: [PATCH 05/19] add an example for #1659 --- _examples/README.md | 3 +- _examples/request-body/read-url/main.go | 38 ++++++++++++++++++++ _examples/request-body/read-url/main_test.go | 16 +++++++++ context/context.go | 5 ++- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 _examples/request-body/read-url/main.go create mode 100644 _examples/request-body/read-url/main_test.go diff --git a/_examples/README.md b/_examples/README.md index 71839aa156..9383dd2869 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) diff --git a/_examples/request-body/read-url/main.go b/_examples/request-body/read-url/main.go new file mode 100644 index 0000000000..4dd0401452 --- /dev/null +++ b/_examples/request-body/read-url/main.go @@ -0,0 +1,38 @@ +// package main contains an example on how to use the ReadParams, +// same way you can do the ReadQuery, 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 0000000000..211069004b --- /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/context/context.go b/context/context.go index 5eaca26d63..3932953665 100644 --- a/context/context.go +++ b/context/context.go @@ -2363,7 +2363,10 @@ func (ctx *Context) ReadParams(ptr interface{}) error { // 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. +// If a validator exists then it validates the result too. +// +// Note that if the registered route contains a tail path parameter +// it may override any URL queries. func (ctx *Context) ReadURL(ptr interface{}) error { values := make(map[string][]string, ctx.params.Len()) ctx.params.Visit(func(key string, value string) { From d0add202d974f60b6704795b5921a18a5e6ba5f3 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 19:38:23 +0300 Subject: [PATCH 06/19] minor --- _examples/request-body/read-url/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_examples/request-body/read-url/main.go b/_examples/request-body/read-url/main.go index 4dd0401452..36521614c1 100644 --- a/_examples/request-body/read-url/main.go +++ b/_examples/request-body/read-url/main.go @@ -1,5 +1,5 @@ -// package main contains an example on how to use the ReadParams, -// same way you can do the ReadQuery, ReadJSON, ReadProtobuf and e.t.c. +// 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 ( From 83462d2999d12524cc1b3265cf0b723e7967fce2 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 19:39:31 +0300 Subject: [PATCH 07/19] minor: godoc: remove invalid comment --- context/context.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/context/context.go b/context/context.go index 3932953665..3caff8d96c 100644 --- a/context/context.go +++ b/context/context.go @@ -2364,9 +2364,6 @@ func (ctx *Context) ReadParams(ptr interface{}) error { // 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. -// -// Note that if the registered route contains a tail path parameter -// it may override any URL queries. func (ctx *Context) ReadURL(ptr interface{}) error { values := make(map[string][]string, ctx.params.Len()) ctx.params.Visit(func(key string, value string) { From 3db77684ecdcc4164f56ace1baa27975af538f4b Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 20:31:58 +0300 Subject: [PATCH 08/19] add a very simple example on JWT and move the previous to the 'overview' sub folder --- HISTORY.md | 2 +- _examples/README.md | 4 +- _examples/auth/jwt/basic/main.go | 44 +++++++++++++++++++ _examples/auth/jwt/{ => overview}/README.md | 0 _examples/auth/jwt/{ => overview}/main.go | 0 .../{ => overview}/rsa_password_protected.key | 0 middleware/jwt/jwt.go | 15 +++++++ 7 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 _examples/auth/jwt/basic/main.go rename _examples/auth/jwt/{ => overview}/README.md (100%) rename _examples/auth/jwt/{ => overview}/main.go (100%) rename _examples/auth/jwt/{ => overview}/rsa_password_protected.key (100%) diff --git a/HISTORY.md b/HISTORY.md index 7e885aac67..82990b49ef 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) or [jwt](https://github.com/kataras/iris/blob/master/_examples/auth/jwt/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` diff --git a/_examples/README.md b/_examples/README.md index 9383dd2869..da8c458bbd 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -198,7 +198,9 @@ * Authentication, Authorization & Bot Detection * [Basic Authentication](auth/basicauth/main.go) * [CORS](auth/cors) - * [JWT](auth/jwt/main.go) + * JSON Web Tokens + * [Overview](auth/jwt/overview/main.go) + * [Basic](auth/jwt/basic/main.go) * [Refresh Token](auth/jwt/refresh-token/main.go) * [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go) * [OAUth2](auth/goth/main.go) diff --git a/_examples/auth/jwt/basic/main.go b/_examples/auth/jwt/basic/main.go new file mode 100644 index 0000000000..f4b36699fb --- /dev/null +++ b/_examples/auth/jwt/basic/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/jwt" +) + +func main() { + app := iris.New() + // With AES-GCM (128) encryption: + // j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") + // Without extra encryption, just the sign key: + j := jwt.HMAC(15*time.Minute, "secret") + + app.Get("/", generateToken(j)) + app.Get("/protected", j.VerifyMap(), protected) + + app.Listen(":8080") +} + +func generateToken(j *jwt.JWT) iris.Handler { + return func(ctx iris.Context) { + token, err := j.Token(iris.Map{ + "foo": "bar", + }) + if err != nil { + ctx.StopWithStatus(iris.StatusInternalServerError) + return + } + + ctx.HTML(`Token: ` + token + `

+ /secured?token=` + token + ``) + } +} + +func protected(ctx iris.Context) { + ctx.Writef("This is an authenticated request.\n\n") + + claims := jwt.Get(ctx).(iris.Map) + + ctx.Writef("foo=%s\n", claims["foo"]) +} diff --git a/_examples/auth/jwt/README.md b/_examples/auth/jwt/overview/README.md similarity index 100% rename from _examples/auth/jwt/README.md rename to _examples/auth/jwt/overview/README.md diff --git a/_examples/auth/jwt/main.go b/_examples/auth/jwt/overview/main.go similarity index 100% rename from _examples/auth/jwt/main.go rename to _examples/auth/jwt/overview/main.go diff --git a/_examples/auth/jwt/rsa_password_protected.key b/_examples/auth/jwt/overview/rsa_password_protected.key similarity index 100% rename from _examples/auth/jwt/rsa_password_protected.key rename to _examples/auth/jwt/overview/rsa_password_protected.key diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index f449383e82..34ef36bc49 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -246,6 +246,9 @@ func getenv(key string, def string) string { // // It panics on errors. // Use the `New` package-level function instead for more options. +// +// Example at: +// https://github.com/kataras/iris/tree/master/_examples/auth/jwt/overview/main.go func HMAC(maxAge time.Duration, keys ...string) *JWT { var defaultSignSecret, defaultEncSecret string @@ -767,8 +770,20 @@ func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) conte } } +// VerifyMap is a shortcut of Verify with a function which will bind +// the claims to a standard Go map[string]interface{}. +func (j *JWT) VerifyMap(exceptions ...Expectation) context.Handler { + return j.Verify(func() interface{} { + return &context.Map{} + }) +} + // VerifyJSON works like `Verify` but instead it // binds its "newPtr" function to return a raw JSON message. +// It does NOT read the token from JSON by itself, +// to do that add the `FromJSON` to the Token Extractors. +// It's used to bind the claims in any value type on the next handler. +// // This allows the caller to bind this JSON message to any Go structure (or map). // This is useful when we can accept more than one // type of JWT token in the same request path, From 02bfc83f2a76d01e0a257e9289f0e49e524b55f0 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 18 Oct 2020 21:51:25 +0300 Subject: [PATCH 09/19] jwt: make the Blocklist an interface, so end-developers can implement their own storage (e.g. redis) --- middleware/jwt/blocklist.go | 41 +++++++++++++++++++++++++++---------- middleware/jwt/jwt.go | 10 ++++----- middleware/jwt/jwt_test.go | 2 +- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go index 866226557a..377ca0274d 100644 --- a/middleware/jwt/blocklist.go +++ b/middleware/jwt/blocklist.go @@ -6,27 +6,46 @@ import ( "time" ) -// Blocklist is an in-memory storage of tokens that should be +// 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 { + // Set should upsert a token to the storage. + Set(token string, expiresAt time.Time) + // Del should remove a token from the storage. + Del(token string) + // Count should return the total amount of tokens stored. + Count() int + // Has should report whether a specific token exists in the storage. + Has(token string) bool +} + +// blocklist is an in-memory storage of tokens that should be // immediately invalidated by the server-side. // The most common way to invalidate a token, e.g. on user logout, // is to make the client-side remove the token itself. // However, if someone else has access to that token, // it could be still valid for new requests until its expiration. -type Blocklist struct { +type blocklist struct { entries map[string]time.Time // key = token | value = expiration time (to remove expired). mu sync.RWMutex } // NewBlocklist returns a new up and running in-memory Token Blocklist. // The returned value can be set to the JWT instance's Blocklist field. -func NewBlocklist(gcEvery time.Duration) *Blocklist { +func NewBlocklist(gcEvery time.Duration) Blocklist { return NewBlocklistContext(stdContext.Background(), gcEvery) } // NewBlocklistContext same as `NewBlocklist` // but it also accepts a standard Go Context for GC cancelation. -func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) *Blocklist { - b := &Blocklist{ +func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) Blocklist { + b := &blocklist{ entries: make(map[string]time.Time), } @@ -39,21 +58,21 @@ func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) *Blockli // Set upserts a given token, with its expiration time, // to the block list, so it's immediately invalidated by the server-side. -func (b *Blocklist) Set(token string, expiresAt time.Time) { +func (b *blocklist) Set(token string, expiresAt time.Time) { b.mu.Lock() b.entries[token] = expiresAt b.mu.Unlock() } // Del removes a "token" from the block list. -func (b *Blocklist) Del(token string) { +func (b *blocklist) Del(token string) { b.mu.Lock() delete(b.entries, token) b.mu.Unlock() } // Count returns the total amount of blocked tokens. -func (b *Blocklist) Count() int { +func (b *blocklist) Count() int { b.mu.RLock() n := len(b.entries) b.mu.RUnlock() @@ -64,7 +83,7 @@ func (b *Blocklist) Count() int { // Has reports whether the given "token" is blocked by the server. // This method is called before the token verification, // so even if was expired it is removed from the block list. -func (b *Blocklist) Has(token string) bool { +func (b *blocklist) Has(token string) bool { if token == "" { return false } @@ -94,7 +113,7 @@ func (b *Blocklist) Has(token string) bool { // Depending on the application, the GC method can be scheduled // to called every half or a whole hour. // A good value for a GC cron task is the JWT's max age (default). -func (b *Blocklist) GC() int { +func (b *blocklist) GC() int { now := time.Now() var markedForDeletion []string @@ -116,7 +135,7 @@ func (b *Blocklist) GC() int { return n } -func (b *Blocklist) runGC(ctx stdContext.Context, every time.Duration) { +func (b *blocklist) runGC(ctx stdContext.Context, every time.Duration) { t := time.NewTicker(every) for { diff --git a/middleware/jwt/jwt.go b/middleware/jwt/jwt.go index 34ef36bc49..3a69723af9 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -116,13 +116,13 @@ type JWT struct { // Blocklist holds the invalidated-by-server tokens (that are not yet expired). // It is not initialized by default. // Initialization Usage: - // j.UseBlocklist() + // j.InitDefaultBlocklist() // OR // j.Blocklist = jwt.NewBlocklist(gcEveryDuration) // Usage: // - ctx.Logout() // - j.Invalidate(ctx) - Blocklist *Blocklist + Blocklist Blocklist } type privateKey interface{ Public() crypto.PublicKey } @@ -301,11 +301,11 @@ func (j *JWT) WithEncryption(contentEncryption ContentEncryption, alg KeyAlgorit return nil } -// UseBlocklist initializes the Blocklist. +// InitDefaultBlocklist initializes the Blocklist field with the default in-memory implementation. // Should be called on jwt middleware creation-time, // after this, the developer can use the Context.Logout method // to invalidate a verified token by the server-side. -func (j *JWT) UseBlocklist() { +func (j *JWT) InitDefaultBlocklist() { gcEvery := 30 * time.Minute if j.MaxAge > 0 { gcEvery = j.MaxAge @@ -515,7 +515,7 @@ func GetTokenInfo(ctx *context.Context) *TokenInfo { // This method can be used when the client-side does not clear the token // on a user logout operation. // -// Note: the Blocklist should be initialized before serve-time: j.UseBlocklist(). +// Note: the Blocklist should be initialized before serve-time: j.InitDefaultBlocklist(). func (j *JWT) Invalidate(ctx *context.Context) { if j.Blocklist == nil { ctx.Application().Logger().Debug("jwt.Invalidate: Blocklist is nil") diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index b3e8c42262..277ce7bb27 100644 --- a/middleware/jwt/jwt_test.go +++ b/middleware/jwt/jwt_test.go @@ -63,7 +63,7 @@ func TestVerify(t *testing.T) { func testWriteVerifyBlockToken(t *testing.T, j *jwt.JWT) { t.Helper() - j.UseBlocklist() + j.InitDefaultBlocklist() j.Extractors = append(j.Extractors, jwt.FromJSON("access_token")) customClaims := &userClaims{ From d517f36a29a6479e40c36bc08c715901c1ddf189 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Mon, 19 Oct 2020 10:05:48 +0300 Subject: [PATCH 10/19] minor commit. But I am not happy with the jwt implementations for Go...they seem to produce enough performance cost, will try to make a jwt parser by myself and see the difference --- HISTORY.md | 2 +- configuration.go | 2 +- context/context.go | 10 +++++----- middleware/accesslog/accesslog.go | 2 +- middleware/jwt/jwt.go | 25 ++++++++++++++++++++----- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 82990b49ef..30ca6fe1a7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -317,7 +317,7 @@ var dirOpts = iris.DirOptions{ - `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 `Context.GetErrPublic() (bool, error)`, `Context.SetErrPrivate(err error)` methods 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.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). diff --git a/configuration.go b/configuration.go index e653e7df06..bc9f04e8e2 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 3caff8d96c..00edad58bd 100644 --- a/context/context.go +++ b/context/context.go @@ -2130,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. @@ -2146,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) { diff --git a/middleware/accesslog/accesslog.go b/middleware/accesslog/accesslog.go index 090cad8185..195669b33e 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/jwt/jwt.go b/middleware/jwt/jwt.go index 3a69723af9..a2d8172cb8 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -58,6 +58,8 @@ func FromJSON(jsonKey string) TokenExtractor { } var m context.Map + ctx.RecordRequestBody(true) + defer ctx.RecordRequestBody(false) if err := ctx.ReadJSON(&m); err != nil { return "" } @@ -362,6 +364,16 @@ func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(c).CompactSerialize() } else { token, err = jwt.Signed(j.Signer).Claims(c).CompactSerialize() + // payload, pErr := Marshal(c) + // if pErr != nil { + // return "", pErr + // } + // sign, sErr := j.Signer.Sign(payload) + // if sErr != nil { + // return "", sErr + // } + + // token, err = sign.CompactSerialize() } if err != nil { @@ -409,6 +421,9 @@ func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}, expectati func (j *JWT) VerifyRefreshToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { token := j.RequestToken(ctx) if token == "" { + ctx.RecordRequestBody(true) + defer ctx.RecordRequestBody(false) + var tokenPair TokenPair // read "refresh_token" from JSON. if ctx.GetContentTypeRequested() == context.ContentJSONHeaderValue { ctx.ReadJSON(&tokenPair) // ignore error. @@ -772,10 +787,10 @@ func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) conte // VerifyMap is a shortcut of Verify with a function which will bind // the claims to a standard Go map[string]interface{}. -func (j *JWT) VerifyMap(exceptions ...Expectation) context.Handler { +func (j *JWT) VerifyMap(expections ...Expectation) context.Handler { return j.Verify(func() interface{} { return &context.Map{} - }) + }, expections...) } // VerifyJSON works like `Verify` but instead it @@ -797,7 +812,7 @@ func (j *JWT) VerifyMap(exceptions ...Expectation) context.Handler { func (j *JWT) VerifyJSON(expections ...Expectation) context.Handler { return j.Verify(func() interface{} { return new(json.RawMessage) - }) + }, expections...) } // ReadJSON is a helper which binds "claimsPtr" to the @@ -839,8 +854,8 @@ func (j *JWT) NewUser(opts ...UserOption) *User { // it unmarshals the token to the specific User type. // It sets the Context User too. So the next handler(s) // of the same chain can access the User through a `Context.User()` call. -func (j *JWT) VerifyUser() context.Handler { +func (j *JWT) VerifyUser(expectations ...Expectation) context.Handler { return j.Verify(func() interface{} { return new(User) - }) + }, expectations...) } From 8eea0296a740df2585015cade4f44de65b209ade Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Fri, 30 Oct 2020 22:12:16 +0200 Subject: [PATCH 11/19] As noticed in my previous commit, the existing jwt libraries added a lot of performance cost between jwt-featured requests and simple requests. That's why a new custom JWT parser was created. This commit adds our custom jwt parser as the underline token signer and verifier --- HISTORY.md | 2 +- NOTICE | 6 +- _examples/auth/jwt/basic/main.go | 76 +- _examples/auth/jwt/middleware/main.go | 91 ++ _examples/auth/jwt/overview/README.md | 29 - _examples/auth/jwt/overview/main.go | 368 -------- .../jwt/overview/rsa_password_protected.key | 30 - _examples/auth/jwt/refresh-token/main.go | 129 ++- .../jwt/refresh-token/rsa_private_key.pem | 27 + .../auth/jwt/refresh-token/rsa_public_key.pem | 9 + go.mod | 20 +- middleware/jwt/alises.go | 156 ++-- middleware/jwt/blocklist.go | 138 +-- middleware/jwt/extractor.go | 71 ++ middleware/jwt/jwt.go | 856 +----------------- middleware/jwt/jwt_test.go | 345 +------ middleware/jwt/rsa_util.go | 106 --- middleware/jwt/signer.go | 59 ++ middleware/jwt/user.go | 187 ---- middleware/jwt/validation.go | 258 ------ middleware/jwt/verifier.go | 210 +++++ 21 files changed, 746 insertions(+), 2427 deletions(-) create mode 100644 _examples/auth/jwt/middleware/main.go delete mode 100644 _examples/auth/jwt/overview/README.md delete mode 100644 _examples/auth/jwt/overview/main.go delete mode 100644 _examples/auth/jwt/overview/rsa_password_protected.key create mode 100644 _examples/auth/jwt/refresh-token/rsa_private_key.pem create mode 100644 _examples/auth/jwt/refresh-token/rsa_public_key.pem create mode 100644 middleware/jwt/extractor.go delete mode 100644 middleware/jwt/rsa_util.go create mode 100644 middleware/jwt/signer.go delete mode 100644 middleware/jwt/user.go delete mode 100644 middleware/jwt/validation.go create mode 100644 middleware/jwt/verifier.go diff --git a/HISTORY.md b/HISTORY.md index 30ca6fe1a7..fcccde5f7d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -262,7 +262,7 @@ var dirOpts = iris.DirOptions{ - New builtin [requestid](https://github.com/kataras/iris/tree/master/middleware/requestid) middleware. -- New builtin [JWT](https://github.com/kataras/iris/tree/master/middleware/jwt) middleware based on [square/go-jose](https://github.com/square/go-jose) featured with optional encryption to set claims with sensitive data when necessary. +- New builtin [JWT](https://github.com/kataras/iris/tree/master/middleware/jwt) middleware based on the fastest JWT implementation; [kataras/jwt](https://github.com/kataras/jwt) featured with optional wire encryption to set claims with sensitive data when necessary. - New `iris.RouteOverlap` route registration rule. `Party.SetRegisterRule(iris.RouteOverlap)` to allow overlapping across multiple routes for the same request subdomain, method, path. See [1536#issuecomment-643719922](https://github.com/kataras/iris/issues/1536#issuecomment-643719922). This allows two or more **MVC Controllers** to listen on the same path based on one or more registered dependencies (see [_examples/mvc/authenticated-controller](https://github.com/kataras/iris/tree/master/_examples/mvc/authenticated-controller)). diff --git a/NOTICE b/NOTICE index 5f0c9defbd..042230a09e 100644 --- a/NOTICE +++ b/NOTICE @@ -101,9 +101,9 @@ Revision ID: 5fc50a00491616d5cd0cbce3abd8b699838e25ca toml 3012a1dbe2e4bd1 https://github.com/BurntSushi/toml 391d42b32f0577c b7bbc7f005 - jose d84c719419c2a90 https://github.com/square/go-jose - 8d188ea67e09652 - f5c1929ae8 + jwt 5f34e0a4e28178b https://github.com/kataras/jwt + 3781df69552bdc5 + 481a0d4bef uuid cb32006e483f2a2 https://github.com/google/uuid 3230e24209cf185 c65b477dbf diff --git a/_examples/auth/jwt/basic/main.go b/_examples/auth/jwt/basic/main.go index f4b36699fb..f37ce681fb 100644 --- a/_examples/auth/jwt/basic/main.go +++ b/_examples/auth/jwt/basic/main.go @@ -4,41 +4,75 @@ import ( "time" "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/middleware/jwt" + "github.com/kataras/jwt" ) +/* +Learn how to use any JWT 3rd-party package with Iris. +In this example we use the kataras/jwt one. + +Install with: + go get -u github.com/kataras/jwt + +Documentation: + https://github.com/kataras/jwt#table-of-contents +*/ + +// Replace with your own key and keep them secret. +// The "signatureSharedKey" is used for the HMAC(HS256) signature algorithm. +var signatureSharedKey = []byte("sercrethatmaycontainch@r32length") + func main() { app := iris.New() - // With AES-GCM (128) encryption: - // j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - // Without extra encryption, just the sign key: - j := jwt.HMAC(15*time.Minute, "secret") - app.Get("/", generateToken(j)) - app.Get("/protected", j.VerifyMap(), protected) + app.Get("/", generateToken) + app.Get("/protected", protected) app.Listen(":8080") } -func generateToken(j *jwt.JWT) iris.Handler { - return func(ctx iris.Context) { - token, err := j.Token(iris.Map{ - "foo": "bar", - }) - if err != nil { - ctx.StopWithStatus(iris.StatusInternalServerError) - return - } - - ctx.HTML(`Token: ` + token + `

- /secured?token=` + token + ``) +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") - claims := jwt.Get(ctx).(iris.Map) + // 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\n", claims["foo"]) + ctx.Writef("foo=%s\nexpires at: %s\ntime left: %s\n", claims.Foo, expiresAtString, timeLeft) } diff --git a/_examples/auth/jwt/middleware/main.go b/_examples/auth/jwt/middleware/main.go new file mode 100644 index 0000000000..948a0c4c24 --- /dev/null +++ b/_examples/auth/jwt/middleware/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "time" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/jwt" +) + +var ( + sigKey = []byte("signature_hmac_secret_shared_key") + encKey = []byte("GCM_AES_256_secret_shared_key_32") +) + +type fooClaims struct { + Foo string `json:"foo"` +} + +/* +In this example you will learn the essentials +of the Iris builtin JWT middleware based on the github.com/kataras/jwt package. +*/ + +func main() { + app := iris.New() + + signer := jwt.NewSigner(jwt.HS256, sigKey, 10*time.Minute) + // Enable payload encryption with: + // signer.WithGCM(encKey, nil) + app.Get("/", generateToken(signer)) + + verifier := jwt.NewVerifier(jwt.HS256, sigKey) + // Enable server-side token block feature (even before its expiration time): + verifier.WithDefaultBlocklist() + // Enable payload decryption with: + // verifier.WithGCM(encKey, nil) + verifyMiddleware := verifier.Verify(func() interface{} { + return new(fooClaims) + }) + + protectedAPI := app.Party("/protected") + // Register the verify middleware to allow access only to authorized clients. + protectedAPI.Use(verifyMiddleware) + // ^ or UseRouter(verifyMiddleware) to disallow unauthorized http error handlers too. + + protectedAPI.Get("/", protected) + // Invalidate the token through server-side, even if it's not expired yet. + protectedAPI.Get("/logout", logout) + + // http://localhost:8080 + // http://localhost:8080/protected?token=$token (or Authorization: Bearer $token) + // http://localhost:8080/protected/logout?token=$token + // http://localhost:8080/protected?token=$token (401) + app.Listen(":8080") +} + +func generateToken(signer *jwt.Signer) iris.Handler { + return func(ctx iris.Context) { + claims := fooClaims{Foo: "bar"} + + token, err := signer.Sign(claims) + if err != nil { + ctx.StopWithStatus(iris.StatusInternalServerError) + return + } + + ctx.Write(token) + } +} + +func protected(ctx iris.Context) { + // Get the verified and decoded claims. + claims := jwt.Get(ctx).(*fooClaims) + + // Optionally, get token information if you want to work with them. + // Just an example on how you can retrieve all the standard claims (set by signer's max age, "exp"). + standardClaims := jwt.GetVerifiedToken(ctx).StandardClaims + expiresAtString := standardClaims.ExpiresAt().Format(ctx.Application().ConfigurationReadOnly().GetTimeFormat()) + timeLeft := standardClaims.Timeleft() + + ctx.Writef("foo=%s\nexpires at: %s\ntime left: %s\n", claims.Foo, expiresAtString, timeLeft) +} + +func logout(ctx iris.Context) { + err := ctx.Logout() + if err != nil { + ctx.WriteString(err.Error()) + } else { + ctx.Writef("token invalidated, a new token is required to access the protected API") + } +} diff --git a/_examples/auth/jwt/overview/README.md b/_examples/auth/jwt/overview/README.md deleted file mode 100644 index 1d2e0ae50a..0000000000 --- a/_examples/auth/jwt/overview/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Generate RSA - -```sh -$ openssl genrsa -des3 -out private_rsa.pem 2048 -``` - -```go -b, err := ioutil.ReadFile("./private_rsa.pem") -if err != nil { - panic(err) -} -key := jwt.MustParseRSAPrivateKey(b, []byte("pass")) -``` - -OR - -```go -import "crypto/rand" -import "crypto/rsa" - -key, err := rsa.GenerateKey(rand.Reader, 2048) -``` - -# Generate Ed25519 - -```sh -$ openssl genpkey -algorithm Ed25519 -out private_ed25519.pem -$ openssl req -x509 -key private_ed25519.pem -out cert_ed25519.pem -days 365 -``` diff --git a/_examples/auth/jwt/overview/main.go b/_examples/auth/jwt/overview/main.go deleted file mode 100644 index ee2ef42fc5..0000000000 --- a/_examples/auth/jwt/overview/main.go +++ /dev/null @@ -1,368 +0,0 @@ -// Package main shows how you can use the Iris unique JWT middleware. -// The file contains different kind of examples that all do the same job but, -// depending on your code style and your application's requirements, you may choose one over other. -package main - -import ( - "time" - - "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/middleware/jwt" -) - -// Claims a custom claims structure. -type Claims struct { - // Optionally define JWT's "iss" (Issuer), - // "sub" (Subject) and "aud" (Audience) for issuer and subject. - // The JWT's "exp" (expiration) and "iat" (issued at) are automatically - // set by the middleware. - Issuer string `json:"iss"` - Subject string `json:"sub"` - Audience []string `json:"aud"` - /* - Note that the above fields can be also extracted via: - jwt.GetTokenInfo(ctx).Claims - But in that example, we just showcase how these info can be embedded - inside your own Go structure. - */ - - // Optionally define a "exp" (Expiry), - // unlike the rest, this is unset on creation - // (unless you want to override the middleware's max age option), - // it's filled automatically by the JWT middleware - // when the request token is verified. - // See the POST /user route. - Expiry *jwt.NumericDate `json:"exp"` - - Username string `json:"username"` -} - -func main() { - // Get keys from system's environment variables - // JWT_SECRET (for signing and verification) and JWT_SECRET_ENC(for encryption and decryption), - // or defaults to "secret" and "itsa16bytesecret" respectfully. - // - // Use the `jwt.New` instead for more flexibility, if necessary. - j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - - /* - By default it extracts the token from url parameter "token={token}" - and the Authorization Bearer {token} header. - You can also take token from JSON body: - j.Extractors = append(j.Extractors, jwt.FromJSON) - */ - - /* Optionally, enable block list to force-invalidate - verified tokens even before their expiration time. - This is useful when the client doesn't clear - the token on a user logout by itself. - - The duration argument clears any expired token on each every tick. - There is a GC() method that can be manually called to clear expired blocked tokens - from the memory. - - j.Blocklist = jwt.NewBlocklist(30*time.Minute) - OR NewBlocklistContext(stdContext, 30*time.Minute) - - - To invalidate a verified token just call: - j.Invalidate(ctx) inside a route handler. - */ - - app := iris.New() - app.Logger().SetLevel("debug") - - app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { - // Note that, any error stored by an authentication - // method in Iris is an iris.ErrPrivate. - // Available jwt errors: - // - ErrMissing - // - ErrMissingKey - // - ErrExpired - // - ErrNotValidYet - // - ErrIssuedInTheFuture - // - ErrBlocked - // An iris.ErrPrivate SHOULD never be displayed to the client as it is; - // because it may contain critical security information about the server. - // - // Also keep in mind that JWT middleware logs verification errors to the - // application's logger ("debug") so, normally you don't have to - // bother showing the verification error to the browser/client. - // However, you can retrieve that error and do what ever you feel right: - if err := ctx.GetErr(); err != nil { - // If we have an error stored, - // (JWT middleware stores any verification errors to the Context), - // set the error as response body, - // which is the default behavior if that - // wasn't an authentication error (as explained above) - ctx.WriteString(err.Error()) - } else { - // Else, the default behavior when no error was occured; - // write the status text of the status code: - ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) - } - }) - - app.Get("/authenticate", func(ctx iris.Context) { - claims := &Claims{ - Issuer: "server", - Audience: []string{"user"}, - Username: "kataras", - } - - // WriteToken generates and sends the token to the client. - // To generate a token use: tok, err := j.Token(claims) - // then you can write it in any form you'd like. - // The expiration JWT fields are automatically - // set by the middleware, that means that your claims value - // only needs to fill fields that your application specifically requires. - j.WriteToken(ctx, claims) - }) - - // Middleware + type-safe method, - // useful in 99% of the cases, when your application - // requires token verification under a whole path prefix, e.g. /protected: - protectedAPI := app.Party("/protected") - { - protectedAPI.Use(j.Verify(func() interface{} { - // Must return a pointer to a type. - // - // The Iris JWT implementation is very sophisticated. - // We keep our claims in type-safe form. - // However, you are free to use raw Go maps - // (map[string]interface{} or iris.Map) too (example later on). - // - // Note that you can use the same "j" JWT instance - // to serve different types of claims on other group of routes, - // e.g. postRouter.Use(j.Verify(... return new(Post))). - return new(Claims) - })) - - protectedAPI.Get("/", func(ctx iris.Context) { - claims := jwt.Get(ctx).(*Claims) - // All fields parsed from token are set to the claims, - // including the Expiry (if defined). - ctx.Writef("Username: %s\nExpires at: %s\nAudience: %s", - claims.Username, claims.Expiry.Time(), claims.Audience) - }) - } - - // Verify token inside a handler method, - // useful when you just need to verify a token on a single spot: - app.Get("/inline", func(ctx iris.Context) { - var claims Claims - _, err := j.VerifyToken(ctx, &claims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", - claims.Username, claims.Expiry.Time()) - }) - - // Use a common map as claims method, - // not recommended, as we support typed claims but - // you can do it: - app.Get("/map/authenticate", func(ctx iris.Context) { - claims := map[string]interface{}{ // or iris.Map for shortcut. - "username": "kataras", - } - - j.WriteToken(ctx, claims) - }) - - app.Get("/map/verify/middleware", j.Verify(func() interface{} { - return &iris.Map{} // or &map[string]interface{}{} - }), func(ctx iris.Context) { - claims := jwt.Get(ctx).(iris.Map) - // The Get method will unwrap the *iris.Map for you, - // so its values are directly accessible: - ctx.Writef("Username: %s\nExpires at: %s\n", - claims["username"], claims["exp"].(*jwt.NumericDate).Time()) - }) - - app.Get("/map/verify", func(ctx iris.Context) { - claims := make(iris.Map) // or make(map[string]interface{}) - - tokenInfo, err := j.VerifyToken(ctx, &claims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - ctx.Writef("Username: %s\nExpires at: %s\n", - claims["username"], tokenInfo.Claims.Expiry.Time()) /* the claims["exp"] is also set. */ - }) - - // Use the new Context.User() to retrieve the verified client method: - // 1. Create a go stuct that implements the context.User interface: - app.Get("/users/authenticate", func(ctx iris.Context) { - user := &User{Username: "kataras"} - j.WriteToken(ctx, user) - }) - usersAPI := app.Party("/users") - { - usersAPI.Use(j.Verify(func() interface{} { - return new(User) - })) - - usersAPI.Get("/", func(ctx iris.Context) { - user := ctx.User() - userToken, _ := user.GetToken() - /* - You can also cast it to the underline implementation - and work with its fields: - expires := user.(*User).Expiry.Time() - */ - // OR use the GetTokenInfo to get the parsed token information: - expires := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() - lifetime := expires.Sub(time.Now()) // remeaning time to be expired. - - ctx.Writef("Username: %s\nAuthenticated at: %s\nLifetime: %s\nToken: %s\n", - user.GetUsername(), user.GetAuthorizedAt(), lifetime, userToken) - }) - } - - // http://localhost:8080/authenticate - // http://localhost:8080/protected?token={token} - // http://localhost:8080/inline?token={token} - // - // http://localhost:8080/map/authenticate - // http://localhost:8080/map/verify?token={token} - // http://localhost:8080/map/verify/middleware?token={token} - // - // http://localhost:8080/users/authenticate - // http://localhost:8080/users?token={token} - app.Listen(":8080") -} - -// User is a custom implementation of the Iris Context User interface. -// Optionally, for JWT, you can also implement -// the SetToken(tok string) and -// Validate(ctx iris.Context, claims jwt.Claims, e jwt.Expected) error -// methods to set a token and add custom validation -// to a User value parsed from a token. -type User struct { - iris.User - Username string `json:"username"` - - // Optionally, declare some JWT fields, - // they are automatically filled by the middleware itself. - IssuedAt *jwt.NumericDate `json:"iat"` - Expiry *jwt.NumericDate `json:"exp"` - Token string `json:"-"` -} - -// GetUsername returns the Username. -// Look the iris/context.SimpleUser type -// for all the methods you can implement. -func (u *User) GetUsername() string { - return u.Username -} - -// GetAuthorizedAt returns the IssuedAt time. -// This and the Get/SetToken methods showcase how you can map JWT standard fields -// to an Iris Context User. -func (u *User) GetAuthorizedAt() time.Time { - return u.IssuedAt.Time() -} - -// GetToken is a User interface method. -func (u *User) GetToken() (string, error) { - return u.Token, nil -} - -// SetToken is a special jwt.TokenSetter interface which is -// called automatically when a token is parsed to this User value. -func (u *User) SetToken(tok string) { - u.Token = tok -} - -/* -func default_RSA_Example() { - j := jwt.RSA(15*time.Minute) -} - -Same as: - -func load_File_Or_Generate_RSA_Example() { - signKey, err := jwt.LoadRSA("jwt_sign.key", 2048) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS256, signKey) - if err != nil { - panic(err) - } - - encKey, err := jwt.LoadRSA("jwt_enc.key", 2048) - if err != nil { - panic(err) - } - err = j.WithEncryption(jwt.A128CBCHS256, jwt.RSA15, encKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func hmac_Example() { - // hmac - key := []byte("secret") - j, err := jwt.New(15*time.Minute, jwt.HS256, key) - if err != nil { - panic(err) - } - - // OPTIONAL encryption: - encryptionKey := []byte("itsa16bytesecret") - err = j.WithEncryption(jwt.A128GCM, jwt.DIRECT, encryptionKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func load_From_File_With_Password_Example() { - b, err := ioutil.ReadFile("./rsa_password_protected.key") - if err != nil { - panic(err) - } - signKey,err := jwt.ParseRSAPrivateKey(b, []byte("pass")) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS256, signKey) - if err != nil { - panic(err) - } -} -*/ - -/* -func generate_RSA_Example() { - signKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - panic(err) - } - - encryptionKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - panic(err) - } - - j, err := jwt.New(15*time.Minute, jwt.RS512, signKey) - if err != nil { - panic(err) - } - err = j.WithEncryption(jwt.A128CBCHS256, jwt.RSA15, encryptionKey) - if err != nil { - panic(err) - } -} -*/ diff --git a/_examples/auth/jwt/overview/rsa_password_protected.key b/_examples/auth/jwt/overview/rsa_password_protected.key deleted file mode 100644 index e93fff7761..0000000000 --- a/_examples/auth/jwt/overview/rsa_password_protected.key +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -Proc-Type: 4,ENCRYPTED -DEK-Info: DES-EDE3-CBC,6B0BC214C94124FE - -lAM48DEM/GdCDimr9Vhi+fSHLgduDb0l2BA4uhILgNby51jxY/4X3IqM6f3ImKX7 -cEd9OBug+pwIugB0UW0L0f5Pd59Ovpiaz3xLci1/19ehYnMqsuP3YAnJm40hT5VP -p0gWRiR415PJ0fPeeJPFx5IsqvkTJ30LWZHUZX4EkdcL5L8PrVbmthGDbLh+OcMc -LzoP8eTglzlZF03nyvAol6+p2eZtvOJLu8nWG25q17kyBx6kEiCsWFcUBTX9G7sH -CM3naByDijqZXE/XXtmTMLSRRnlk7Q5WLxClroHlUP9y8BQFMo2TW4Z+vNjHUkc1 -77ghabX1704bAlIE8LLZJKrm/C5+VKyV6117SVG/2bc4036Y5rStXpANbk1j4K0x -ADvpRhuTpifaogdvJP+8eXBdl841MQMRzWuZHp6UNYYQegoV9C+KHyJx4UPjZyzd -gblZmKgU+BsX3mV6MLhJtd6dheLZtpBsAlSstJxzmgwqz9faONYEGeItXO+NnxbA -mxAp/mI+Fz2jfgYlWjwkyPPzD4k/ZMMzB4XLkKKs9XaxUtTomiDkuYZfnABhxt73 -xBy40V1rb/NyeW80pk1zEHM6Iy/48ETSp9U3k9sSOXjMhYbPXgxDtimV8w0qGFAo -2Tif7ZuaiuC38rOkoHK9C6vy2Dp8lQZ+QBnUKLeFsyhq9CaqSdnyUTMj3oEZXXf+ -TqqeO+PTtl7JaNfGRq6/aMZqxACHkyVUvYvjZzx07CJ2fr+OtNqxallM6Oc/o9NZ -5u7lpgrYaKM/b67q0d2X/AoxR5zrZuM8eam3acD1PwHFQKbJWuFNmjWtnlZNuR3X -fZEmxIKwDlup8TxFcqbbZtPHuQA2mTMTqfRkf8oPSO+N6NNaUpb0ignYyA7Eu5GT -b02d/oNLETMikxUxntMSH7GhuOpfJyELz8krYTttbJ+a93h4wBeYW2+LyAr/cRLB -mbtKLtaN7f3FaOSnu8e0+zlJ7xglHPXqblRL9q6ZDM5UJtJD4rA7LPZHk/0Y1Kb6 -hBh1qMDu0r3IV4X7MDacvxw7aa7D8TyXJiFSvxykVhds+ndjIe51Ics5908+lev3 -nwE69PLMwyqe2vvE2oDwao4XJuBLCHjcv/VagRSz/XQGMbZqb3L6unyd3UPl8JjP -ovipNwM4rFnE54uiUUeki7TZGDYO72vQcSaLrmbeAWc2m202+rqLz0WMm6HpPmCv -IgexpX2MnIeHJ3+BlEjA2u+S6xNSD7qHGk2pb7DD8nRvUdSHAHeaQbrkEfEhhR2Q -Dw5gdw1JyQ0UKBl5ndn/1Ub2Asl016lZjpqHyMIVS4tFixACDsihEYMmq/zQmTj4 -8oBZTU+fycN/KiGKZBsqxIwgYIeMz/GfvoyN5m57l6fwEZALVpveI1pP4fiZB/Z8 -xLKa5JK6L10lAD1YHWc1dPhamf9Sb3JwN2CFtGvjOJ/YjAZu3jJoxi40DtRkE3Rh -HI8Cbx1OORzoo0kO0vy42rz5qunYyVmEzPKtOj+YjVEhVJ85yJZ9bTZtuyqMv8mH -cnwEeIFK8cmm9asbVzQGDwN/UGB4cO3LrMX1RYk4GRttTGlp0729BbmZmu00RnD/ ------END RSA PRIVATE KEY----- diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index f48c8231b5..cccf32667e 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -1,57 +1,65 @@ package main import ( + "fmt" "time" "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/middleware/jwt" ) +const ( + accessTokenMaxAge = 10 * time.Minute + refreshTokenMaxAge = time.Hour +) + +var ( + privateKey, publicKey = jwt.MustLoadRSA("rsa_private_key.pem", "rsa_public_key.pem") + + signer = jwt.NewSigner(jwt.RS256, privateKey, accessTokenMaxAge) + verifier = jwt.NewVerifier(jwt.RS256, publicKey) +) + // UserClaims a custom access claims structure. type UserClaims struct { - // In order to separate refresh and access tokens on validation level: - // - Set a different Issuer, with a field of: Issuer string `json:"iss"` - // - Set the Iris JWT's json tag option "required" on an access token field, - // e.g. Username string `json:"username,required"` - // - Let the middleware validate the correct one based on the given MaxAge, - // which should be different between refresh and max age (refersh should be bigger) - // by setting the `jwt.ExpectRefreshToken` on Verify/VerifyToken/VerifyTokenString - // (see `refreshToken` function below) - ID string `json:"user_id"` + ID string `json:"user_id"` + // Do: `json:"username,required"` to have this field required + // or see the Validate method below instead. Username string `json:"username"` } +// Validate completes the middleware's custom ClaimsValidator. +// It will not accept a token which its claims missing the username field +// (useful to not accept refresh tokens generated by the same algorithm). +func (u *UserClaims) Validate() error { + if u.Username == "" { + return fmt.Errorf("username field is missing") + } + + return nil +} + // For refresh token, we will just use the jwt.Claims // structure which contains the standard JWT fields. func main() { app := iris.New() + app.OnErrorCode(iris.StatusUnauthorized, handleUnauthorized) - j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret") - - app.Get("/authenticate", func(ctx iris.Context) { - generateTokenPair(ctx, j) - }) - - app.Get("/refresh", func(ctx iris.Context) { - refreshToken(ctx, j) - }) + app.Get("/authenticate", generateTokenPair) + app.Get("/refresh", refreshToken) protectedAPI := app.Party("/protected") { - protectedAPI.Use(j.Verify(func() interface{} { + verifyMiddleware := verifier.Verify(func() interface{} { return new(UserClaims) - })) // OR j.VerifyToken(ctx, &claims, jwt.MeetRequirements(&UserClaims{})) + }) + + protectedAPI.Use(verifyMiddleware) protectedAPI.Get("/", func(ctx iris.Context) { - // Get token info, even if our UserClaims does not embed those - // through GetTokenInfo: - expiresAt := jwt.GetTokenInfo(ctx).Claims.Expiry.Time() - // Get your custom JWT claims through Get, - // which is a shortcut of GetTokenInfo(ctx).Value: claims := jwt.Get(ctx).(*UserClaims) - - ctx.Writef("Username: %s\nExpires at: %s\n", claims.Username, expiresAt) + ctx.Writef("Username: %s\n", claims.Username) }) } @@ -59,33 +67,33 @@ func main() { // http://localhost:8080/authenticate (200) (response JSON {access_token, refresh_token}) // http://localhost:8080/protected?token={access_token} (200) // http://localhost:8080/protected?token={refresh_token} (401) - // http://localhost:8080/refresh?token={refresh_token} + // http://localhost:8080/refresh?refresh_token={refresh_token} // OR http://localhost:8080/refresh (request JSON{refresh_token = {refresh_token}}) (200) (response JSON {access_token, refresh_token}) - // OR http://localhost:8080/refresh (request PLAIN TEXT of {refresh_token}) (200) (response JSON {access_token, refresh_token}) - // http://localhost:8080/refresh?token={access_token} (401) + // http://localhost:8080/refresh?refresh_token={access_token} (401) app.Listen(":8080") } -func generateTokenPair(ctx iris.Context, j *jwt.JWT) { +func generateTokenPair(ctx iris.Context) { // Simulate a user... userID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" // Map the current user with the refresh token, // so we make sure, on refresh route, that this refresh token owns // to that user before re-generate. - refresh := jwt.Claims{Subject: userID} + refreshClaims := jwt.Claims{Subject: userID} - access := UserClaims{ + accessClaims := UserClaims{ ID: userID, Username: "kataras", } // Generates a Token Pair, long-live for refresh tokens, e.g. 1 hour. - // Second argument is the refresh claims and, - // the last one is the access token's claims. - tokenPair, err := j.TokenPair(1*time.Hour, refresh, access) + // First argument is the access claims, + // second argument is the refresh claims, + // third argument is the refresh max age. + tokenPair, err := signer.NewTokenPair(accessClaims, refreshClaims, refreshTokenMaxAge) if err != nil { - ctx.Application().Logger().Debugf("token pair: %v", err) + ctx.Application().Logger().Errorf("token pair: %v", err) ctx.StopWithStatus(iris.StatusInternalServerError) return } @@ -95,14 +103,12 @@ func generateTokenPair(ctx iris.Context, j *jwt.JWT) { ctx.JSON(tokenPair) } -func refreshToken(ctx iris.Context, j *jwt.JWT) { - /* - We could pass a jwt.Claims pointer as the second argument, - but we don't have to because the method already returns - the standard JWT claims information back to us: - refresh, err := VerifyRefreshToken(ctx, nil) - */ - +// There are various methods of refresh token, depending on the application requirements. +// In this example we will accept a refresh token only, we will verify only a refresh token +// and we re-generate a whole new pair. An alternative would be to accept a token pair +// of both access and refresh tokens, verify the refresh, verify the access with a Leeway time +// and check if its going to expire soon, then generate a single access token. +func refreshToken(ctx iris.Context) { // Assuming you have access to the current user, e.g. sessions. // // Simulate a database call against our jwt subject @@ -110,23 +116,46 @@ func refreshToken(ctx iris.Context, j *jwt.JWT) { // * Note: You can remove the ExpectSubject and do this validation later on by yourself. currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" + // Get the refresh token from ?refresh_token=$token OR + // the request body's JSON{"refresh_token": "$token"}. + refreshToken := []byte(ctx.URLParam("refresh_token")) + if len(refreshToken) == 0 { + // You can read the whole body with ctx.GetBody/ReadBody too. + var tokenPair jwt.TokenPair + if err := ctx.ReadJSON(&tokenPair); err != nil { + ctx.StopWithError(iris.StatusBadRequest, err) + return + } + + refreshToken = tokenPair.RefreshToken + } + // Verify the refresh token, which its subject MUST match the "currentUserID". - _, err := j.VerifyRefreshToken(ctx, nil, jwt.ExpectSubject(currentUserID)) + _, err := verifier.VerifyToken(refreshToken, jwt.Expected{Subject: currentUserID}) if err != nil { - ctx.Application().Logger().Debugf("verify refresh token: %v", err) + ctx.Application().Logger().Errorf("verify refresh token: %v", err) ctx.StatusCode(iris.StatusUnauthorized) return } /* Custom validation checks can be performed after Verify calls too: currentUserID := "53afcf05-38a3-43c3-82af-8bbbe0e4a149" - userID := refresh.Claims.Subject + userID := verifiedToken.StandardClaims.Subject if userID != currentUserID { ctx.StopWithStatus(iris.StatusUnauthorized) return } */ - // All OK, re-generate the new pair and send to client. - generateTokenPair(ctx, j) + // All OK, re-generate the new pair and send to client, + // we could only generate an access token as well. + generateTokenPair(ctx) +} + +func handleUnauthorized(ctx iris.Context) { + if err := ctx.GetErr(); err != nil { + ctx.Application().Logger().Errorf("unauthorized: %v", err) + } + + ctx.WriteString("Unauthorized") } diff --git a/_examples/auth/jwt/refresh-token/rsa_private_key.pem b/_examples/auth/jwt/refresh-token/rsa_private_key.pem new file mode 100644 index 0000000000..411e3a89ef --- /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 0000000000..99a96176a1 --- /dev/null +++ b/_examples/auth/jwt/refresh-token/rsa_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArwO0q8WbBvrplz3lTQjs +Wu66HC7M3mVAjmjLq8Wj/ipqVtiJMrUL9t/0q9PNO/KX9u+HayFNYM4TnYkXVZX3 +M5E31W8fPPy74D/XpqFwrwT7bAEwpT51JJyxkoBAyOh08lmR2EYvpGF7qErra7qb +kk4LGFbhoFCXdMLXguT4rPymkzFHdQrmGYOBS+v9imSuJddCZpXyv6Ko7AKB4mhz +g4RC5RJZO5GEHVUrSMHxZB0syF8cU+28iL8A7SlGKTNZPZiHmCQVRqA6WlllL/YV +/t6p24kaNZBUp9JGbAzOeKuVUv2uvfNKwB/aBwnFKauM9I6RmC4bnI1nGHjETlNN +WwIDAQAB +-----END PUBLIC KEY----- diff --git a/go.mod b/go.mod index 6f9f219c4e..86a975a635 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.15 require ( github.com/BurntSushi/toml v0.3.1 - github.com/CloudyKit/jet/v5 v5.1.0 + github.com/CloudyKit/jet/v5 v5.1.1 github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 github.com/andybalholm/brotli v1.0.1 github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible @@ -12,7 +12,7 @@ require ( github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/fatih/structs v1.1.0 github.com/flosch/pongo2/v4 v4.0.0 - github.com/go-redis/redis/v8 v8.3.1 + github.com/go-redis/redis/v8 v8.3.3 github.com/google/uuid v1.1.2 github.com/hashicorp/go-version v1.2.1 github.com/iris-contrib/httpexpect/v2 v2.0.5 @@ -21,24 +21,24 @@ require ( github.com/json-iterator/go v1.1.10 github.com/kataras/blocks v0.0.4 github.com/kataras/golog v0.1.5 + github.com/kataras/jwt v0.0.4 github.com/kataras/neffos v0.0.16 github.com/kataras/pio v0.0.10 github.com/kataras/sitemap v0.0.5 github.com/kataras/tunnel v0.0.2 - github.com/klauspost/compress v1.11.1 + github.com/klauspost/compress v1.11.2 github.com/mailru/easyjson v0.7.6 github.com/microcosm-cc/bluemonday v1.0.4 github.com/russross/blackfriday/v2 v2.0.1 github.com/schollz/closestmatch v2.1.0+incompatible - github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 - github.com/tdewolff/minify/v2 v2.9.9 - github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 + github.com/tdewolff/minify/v2 v2.9.10 + github.com/vmihailenco/msgpack/v5 v5.0.0-beta.9 github.com/yosssi/ace v0.0.5 go.etcd.io/bbolt v1.3.5 - golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee - golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 - golang.org/x/sys v0.0.0-20201016160150-f659759dc4ca - golang.org/x/text v0.3.3 + golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 + golang.org/x/net v0.0.0-20201027133719-8eef5233e2a1 + golang.org/x/sys v0.0.0-20201028094953-708e7fb298ac + golang.org/x/text v0.3.4 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e google.golang.org/protobuf v1.25.0 gopkg.in/ini.v1 v1.62.0 diff --git a/middleware/jwt/alises.go b/middleware/jwt/alises.go index da34d9c7a5..df27cb5db9 100644 --- a/middleware/jwt/alises.go +++ b/middleware/jwt/alises.go @@ -1,91 +1,105 @@ package jwt import ( - "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/json" - "github.com/square/go-jose/v3/jwt" + "github.com/kataras/jwt" ) +// Type alises for the underline jwt package. type ( - // Claims represents public claim values (as specified in RFC 7519). + // Alg is the signature algorithm interface alias. + Alg = jwt.Alg + // Claims represents the standard claim values (as specified in RFC 7519). Claims = jwt.Claims - // Audience represents the recipients that the token is intended for. - Audience = jwt.Audience - // NumericDate represents date and time as the number of seconds since the - // epoch, including leap seconds. Non-integer values can be represented - // in the serialized format, but we round to the nearest second. - NumericDate = jwt.NumericDate - // Expected defines values used for protected claims validation. - // If field has zero value then validation is skipped. + // Expected is a TokenValidator which performs simple checks + // between standard claims values. + // + // Usage: + // expecteed := jwt.Expected{ + // Issuer: "my-app", + // } + // verifiedToken, err := verifier.Verify(..., expected) Expected = jwt.Expected + + // TokenValidator is the token validator interface alias. + TokenValidator = jwt.TokenValidator + // VerifiedToken is the type alias for the verfieid token type, + // the result of the VerifyToken function. + VerifiedToken = jwt.VerifiedToken + // SignOption used to set signing options at Sign function. + SignOption = jwt.SignOption + // TokenPair is just a helper structure which holds both access and refresh tokens. + TokenPair = jwt.TokenPair ) +// Signature algorithms. var ( - // NewNumericDate constructs NumericDate from time.Time value. - NewNumericDate = jwt.NewNumericDate - // Marshal returns the JSON encoding of v. - Marshal = json.Marshal - // Unmarshal parses the JSON-encoded data and stores the result - // in the value pointed to by v. - Unmarshal = json.Unmarshal + 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 ) -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 +// 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 ) -// 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 +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 ) -// 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 +// Shortcuts for Signing and Verifying. +var ( + VerifyToken = jwt.Verify + VerifyEncryptedToken = jwt.VerifyEncrypted + Sign = jwt.Sign + SignEncrypted = jwt.SignEncrypted ) -// 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 +// Signature algorithm helpers. +var ( + MustLoadHMAC = jwt.MustLoadHMAC + LoadHMAC = jwt.LoadHMAC + MustLoadRSA = jwt.MustLoadRSA + LoadPrivateKeyRSA = jwt.LoadPrivateKeyRSA + LoadPublicKeyRSA = jwt.LoadPublicKeyRSA + ParsePrivateKeyRSA = jwt.ParsePrivateKeyRSA + ParsePublicKeyRSA = jwt.ParsePublicKeyRSA + MustLoadECDSA = jwt.MustLoadECDSA + LoadPrivateKeyECDSA = jwt.LoadPrivateKeyECDSA + LoadPublicKeyECDSA = jwt.LoadPublicKeyECDSA + ParsePrivateKeyECDSA = jwt.ParsePrivateKeyECDSA + ParsePublicKeyECDSA = jwt.ParsePublicKeyECDSA + MustLoadEdDSA = jwt.MustLoadEdDSA + LoadPrivateKeyEdDSA = jwt.LoadPrivateKeyEdDSA + LoadPublicKeyEdDSA = jwt.LoadPublicKeyEdDSA + ParsePrivateKeyEdDSA = jwt.ParsePrivateKeyEdDSA + ParsePublicKeyEdDSA = jwt.ParsePublicKeyEdDSA ) diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go index 377ca0274d..1c827969b2 100644 --- a/middleware/jwt/blocklist.go +++ b/middleware/jwt/blocklist.go @@ -1,9 +1,7 @@ package jwt import ( - stdContext "context" - "sync" - "time" + "github.com/kataras/jwt" ) // Blocklist should hold and manage invalidated-by-server tokens. @@ -15,136 +13,14 @@ import ( // e.g. a redis one to keep persistence of invalidated tokens on server restarts. // and bind to the JWT middleware's Blocklist field. type Blocklist interface { - // Set should upsert a token to the storage. - Set(token string, expiresAt time.Time) + jwt.TokenValidator + + // InvalidateToken should invalidate a verified JWT token. + InvalidateToken(token []byte, expiry int64) // Del should remove a token from the storage. - Del(token string) + Del(token []byte) // Count should return the total amount of tokens stored. Count() int // Has should report whether a specific token exists in the storage. - Has(token string) bool -} - -// blocklist is an in-memory storage of tokens that should be -// immediately invalidated by the server-side. -// The most common way to invalidate a token, e.g. on user logout, -// is to make the client-side remove the token itself. -// However, if someone else has access to that token, -// it could be still valid for new requests until its expiration. -type blocklist struct { - entries map[string]time.Time // key = token | value = expiration time (to remove expired). - mu sync.RWMutex -} - -// NewBlocklist returns a new up and running in-memory Token Blocklist. -// The returned value can be set to the JWT instance's Blocklist field. -func NewBlocklist(gcEvery time.Duration) Blocklist { - return NewBlocklistContext(stdContext.Background(), gcEvery) -} - -// NewBlocklistContext same as `NewBlocklist` -// but it also accepts a standard Go Context for GC cancelation. -func NewBlocklistContext(ctx stdContext.Context, gcEvery time.Duration) Blocklist { - b := &blocklist{ - entries: make(map[string]time.Time), - } - - if gcEvery > 0 { - go b.runGC(ctx, gcEvery) - } - - return b -} - -// Set upserts a given token, with its expiration time, -// to the block list, so it's immediately invalidated by the server-side. -func (b *blocklist) Set(token string, expiresAt time.Time) { - b.mu.Lock() - b.entries[token] = expiresAt - b.mu.Unlock() -} - -// Del removes a "token" from the block list. -func (b *blocklist) Del(token string) { - b.mu.Lock() - delete(b.entries, token) - b.mu.Unlock() -} - -// Count returns the total amount of blocked tokens. -func (b *blocklist) Count() int { - b.mu.RLock() - n := len(b.entries) - b.mu.RUnlock() - - return n -} - -// Has reports whether the given "token" is blocked by the server. -// This method is called before the token verification, -// so even if was expired it is removed from the block list. -func (b *blocklist) Has(token string) bool { - if token == "" { - return false - } - - b.mu.RLock() - _, ok := b.entries[token] - b.mu.RUnlock() - - /* No, the Blocklist will be used after the token is parsed, - there we can call the Del method if err was ErrExpired. - if ok { - // As an extra step, to keep the list size as small as possible, - // we delete it from list if it's going to be expired - // ~in the next `blockedExpireLeeway` seconds.~ - // - Let's keep it easier for testing by not setting a leeway. - // if time.Now().Add(blockedExpireLeeway).After(expiresAt) { - if time.Now().After(expiresAt) { - b.Del(token) - } - }*/ - - return ok -} - -// GC iterates over all entries and removes expired tokens. -// This method is helpful to keep the list size small. -// Depending on the application, the GC method can be scheduled -// to called every half or a whole hour. -// A good value for a GC cron task is the JWT's max age (default). -func (b *blocklist) GC() int { - now := time.Now() - var markedForDeletion []string - - b.mu.RLock() - for token, expiresAt := range b.entries { - if now.After(expiresAt) { - markedForDeletion = append(markedForDeletion, token) - } - } - b.mu.RUnlock() - - n := len(markedForDeletion) - if n > 0 { - for _, token := range markedForDeletion { - b.Del(token) - } - } - - return n -} - -func (b *blocklist) runGC(ctx stdContext.Context, every time.Duration) { - t := time.NewTicker(every) - - for { - select { - case <-ctx.Done(): - t.Stop() - return - case <-t.C: - b.GC() - } - } + Has(token []byte) bool } diff --git a/middleware/jwt/extractor.go b/middleware/jwt/extractor.go new file mode 100644 index 0000000000..a04dcb98bf --- /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 a2d8172cb8..e2adc31280 100644 --- a/middleware/jwt/jwt.go +++ b/middleware/jwt/jwt.go @@ -1,861 +1,7 @@ package jwt -import ( - "crypto" - "encoding/json" - "fmt" - "os" - "strings" - "time" - - "github.com/kataras/iris/v12/context" - - "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/jwt" -) +import "github.com/kataras/iris/v12/context" func init() { context.SetHandlerName("iris/middleware/jwt.*", "iris.jwt") } - -// TokenExtractor is a function that takes a context as input and returns -// a token. An empty string should be returned if no token found -// without additional information. -type TokenExtractor func(*context.Context) string - -// FromHeader is a token extractor. -// It reads the token from the Authorization request header of form: -// Authorization: "Bearer {token}". -func FromHeader(ctx *context.Context) string { - authHeader := ctx.GetHeader("Authorization") - if authHeader == "" { - return "" - } - - // pure check: authorization header format must be Bearer {token} - authHeaderParts := strings.Split(authHeader, " ") - if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { - return "" - } - - return authHeaderParts[1] -} - -// FromQuery is a token extractor. -// It reads the token from the "token" url query parameter. -func FromQuery(ctx *context.Context) string { - return ctx.URLParam("token") -} - -// FromJSON is a token extractor. -// Reads a json request body and extracts the json based on the given field. -// The request content-type should contain the: application/json header value, otherwise -// this method will not try to read and consume the body. -func FromJSON(jsonKey string) TokenExtractor { - return func(ctx *context.Context) string { - if ctx.GetContentTypeRequested() != context.ContentJSONHeaderValue { - return "" - } - - var m context.Map - ctx.RecordRequestBody(true) - defer ctx.RecordRequestBody(false) - if err := ctx.ReadJSON(&m); err != nil { - return "" - } - - if m == nil { - return "" - } - - v, ok := m[jsonKey] - if !ok { - return "" - } - - tok, ok := v.(string) - if !ok { - return "" - } - - return tok - } -} - -// JWT holds the necessary information the middleware need -// to sign and verify tokens. -// -// The `RSA(privateFile, publicFile, password)` package-level helper function -// can be used to decode the SignKey and VerifyKey. -// -// For an easy use look the `HMAC` package-level function -// and the its `NewUser` and `VerifyUser` methods. -type JWT struct { - // MaxAge is the expiration duration of the generated tokens. - MaxAge time.Duration - - // Extractors are used to extract a raw token string value - // from the request. - // Builtin extractors: - // * FromHeader - // * FromQuery - // * FromJSON - // Defaults to a slice of `FromHeader` and `FromQuery`. - Extractors []TokenExtractor - - // Signer is used to sign the token. - // It is set on `New` and `Default` package-level functions. - Signer jose.Signer - // VerificationKey is used to verify the token (public key). - VerificationKey interface{} - - // Encrypter is used to, optionally, encrypt the token. - // It is set on `WithEncryption` method. - Encrypter jose.Encrypter - // DecriptionKey is used to decrypt the token (private key) - DecriptionKey interface{} - - // Blocklist holds the invalidated-by-server tokens (that are not yet expired). - // It is not initialized by default. - // Initialization Usage: - // j.InitDefaultBlocklist() - // OR - // j.Blocklist = jwt.NewBlocklist(gcEveryDuration) - // Usage: - // - ctx.Logout() - // - j.Invalidate(ctx) - Blocklist Blocklist -} - -type privateKey interface{ Public() crypto.PublicKey } - -// New returns a new JWT instance. -// It accepts a maximum time duration for token expiration -// and the algorithm among with its key for signing and verification. -// -// See `WithEncryption` method to add token encryption too. -// Use `Token` method to generate a new token string -// and `VerifyToken` method to decrypt, verify and bind claims of an incoming request token. -// Token, by default, is extracted by "Authorization: Bearer {token}" request header and -// url query parameter of "token". Token extractors can be modified through the `Extractors` field. -// -// For example, if you want to sign and verify using RSA-256 key: -// 1. Generate key file, e.g: -// $ openssl genrsa -des3 -out private.pem 2048 -// 2. Read file contents with io.ReadFile("./private.pem") -// 3. Pass the []byte result to the `ParseRSAPrivateKey(contents, password)` package-level helper -// 4. Use the result *rsa.PrivateKey as "key" input parameter of this `New` function. -// -// See aliases.go file for available algorithms. -func New(maxAge time.Duration, alg SignatureAlgorithm, key interface{}) (*JWT, error) { - sig, err := jose.NewSigner(jose.SigningKey{ - Algorithm: alg, - Key: key, - }, (&jose.SignerOptions{}).WithType("JWT")) - - if err != nil { - return nil, err - } - - j := &JWT{ - Signer: sig, - VerificationKey: key, - MaxAge: maxAge, - Extractors: []TokenExtractor{FromHeader, FromQuery}, - } - - if s, ok := key.(privateKey); ok { - j.VerificationKey = s.Public() - } - - return j, nil -} - -// Default key filenames for `RSA`. -const ( - DefaultSignFilename = "jwt_sign.key" - DefaultEncFilename = "jwt_enc.key" -) - -// RSA returns a new `JWT` instance. -// It tries to parse RSA256 keys from "filenames[0]" (defaults to "jwt_sign.key") and -// "filenames[1]" (defaults to "jwt_enc.key") files or generates and exports new random keys. -// -// It panics on errors. -// Use the `New` package-level function instead for more options. -func RSA(maxAge time.Duration, filenames ...string) *JWT { - var ( - signFilename = DefaultSignFilename - encFilename = DefaultEncFilename - ) - - switch len(filenames) { - case 1: - signFilename = filenames[0] - case 2: - encFilename = filenames[1] - } - - // Do not try to create or load enc key if only sign key already exists. - withEncryption := true - if fileExists(signFilename) { - withEncryption = fileExists(encFilename) - } - - sigKey, err := LoadRSA(signFilename, 2048) - if err != nil { - panic(err) - } - - j, err := New(maxAge, RS256, sigKey) - if err != nil { - panic(err) - } - - if withEncryption { - encKey, err := LoadRSA(encFilename, 2048) - if err != nil { - panic(err) - } - err = j.WithEncryption(A128CBCHS256, RSA15, encKey) - if err != nil { - panic(err) - } - } - - return j -} - -const ( - signEnv = "JWT_SECRET" - encEnv = "JWT_SECRET_ENC" -) - -func getenv(key string, def string) string { - v := os.Getenv(key) - if v == "" { - return def - } - - return v -} - -// HMAC returns a new `JWT` instance. -// It tries to read hmac256 secret keys from system environment variables: -// * JWT_SECRET for signing and verification key and -// * JWT_SECRET_ENC for encryption and decryption key -// and defaults them to the given "keys" respectfully. -// -// It panics on errors. -// Use the `New` package-level function instead for more options. -// -// Example at: -// https://github.com/kataras/iris/tree/master/_examples/auth/jwt/overview/main.go -func HMAC(maxAge time.Duration, keys ...string) *JWT { - var defaultSignSecret, defaultEncSecret string - - switch len(keys) { - case 1: - defaultSignSecret = keys[0] - case 2: - defaultEncSecret = keys[1] - } - - signSecret := getenv(signEnv, defaultSignSecret) - encSecret := getenv(encEnv, defaultEncSecret) - - j, err := New(maxAge, HS256, []byte(signSecret)) - if err != nil { - panic(err) - } - - if encSecret != "" { - err = j.WithEncryption(A128GCM, DIRECT, []byte(encSecret)) - if err != nil { - panic(err) - } - } - - return j -} - -// WithEncryption method enables encryption and decryption of the token. -// It sets an appropriate encrypter(`Encrypter` and the `DecriptionKey` fields) based on the key type. -func (j *JWT) WithEncryption(contentEncryption ContentEncryption, alg KeyAlgorithm, key interface{}) error { - var publicKey interface{} = key - if s, ok := key.(privateKey); ok { - publicKey = s.Public() - } - - enc, err := jose.NewEncrypter(contentEncryption, jose.Recipient{ - Algorithm: alg, - Key: publicKey, - }, - (&jose.EncrypterOptions{}).WithType("JWT").WithContentType("JWT"), - ) - - if err != nil { - return err - } - - j.Encrypter = enc - j.DecriptionKey = key - return nil -} - -// InitDefaultBlocklist initializes the Blocklist field with the default in-memory implementation. -// Should be called on jwt middleware creation-time, -// after this, the developer can use the Context.Logout method -// to invalidate a verified token by the server-side. -func (j *JWT) InitDefaultBlocklist() { - gcEvery := 30 * time.Minute - if j.MaxAge > 0 { - gcEvery = j.MaxAge - } - j.Blocklist = NewBlocklist(gcEvery) -} - -// ExpiryMap adds the expiration based on the "maxAge" to the "claims" map. -// It's called automatically on `Token` method. -func ExpiryMap(maxAge time.Duration, claims context.Map) { - now := time.Now() - if claims["exp"] == nil { - claims["exp"] = NewNumericDate(now.Add(maxAge)) - } - - if claims["iat"] == nil { - claims["iat"] = NewNumericDate(now) - } -} - -// Token generates and returns a new token string. -// See `VerifyToken` too. -func (j *JWT) Token(claims interface{}) (string, error) { - return j.token(j.MaxAge, claims) -} - -func (j *JWT) token(maxAge time.Duration, claims interface{}) (string, error) { - if claims == nil { - return "", ErrInvalidKey - } - - c, nErr := normalize(claims) - if nErr != nil { - return "", nErr - } - - ExpiryMap(maxAge, c) - - var ( - token string - err error - ) - // jwt.Builder and jwt.NestedBuilder contain same methods but they are not the same. - // - // Note that the .Claims method there, converts a Struct to a map under the hoods. - // That means that we will not have any performance cost - // if we do it by ourselves and pass always a Map there. - // That gives us the option to allow user to pass ANY go struct - // and we can add the "exp", "nbf", "iat" map values by ourselves - // based on the j.MaxAge. - // (^ done, see normalize, all methods are - // changed to accept totally custom types, no need to embed the standard Claims anymore). - if j.DecriptionKey != nil { - token, err = jwt.SignedAndEncrypted(j.Signer, j.Encrypter).Claims(c).CompactSerialize() - } else { - token, err = jwt.Signed(j.Signer).Claims(c).CompactSerialize() - // payload, pErr := Marshal(c) - // if pErr != nil { - // return "", pErr - // } - // sign, sErr := j.Signer.Sign(payload) - // if sErr != nil { - // return "", sErr - // } - - // token, err = sign.CompactSerialize() - } - - if err != nil { - return "", err - } - - return token, nil -} - -// WriteToken is a helper which just generates(calls the `Token` method) and writes -// a new token to the client in plain text format. -// -// Use the `Token` method to get a new generated token raw string value. -func (j *JWT) WriteToken(ctx *context.Context, claims interface{}) error { - token, err := j.Token(claims) - if err != nil { - ctx.StatusCode(500) - return err - } - - _, err = ctx.WriteString(token) - return err -} - -// VerifyToken verifies (and decrypts) the request token, -// it also validates and binds the parsed token's claims to the "claimsPtr" (destination). -// -// The last, variadic, input argument is optionally, if provided then the -// parsed claims must match the expectations; -// e.g. Audience, Issuer, ID, Subject. -// See `ExpectXXX` package-functions for details. -func (j *JWT) VerifyToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { - token := j.RequestToken(ctx) - return j.VerifyTokenString(ctx, token, claimsPtr, expectations...) -} - -// VerifyRefreshToken like the `VerifyToken` but it verifies a refresh token one instead. -// If the implementation does not fill the application's requirements, -// you can ignore this method and still use the `VerifyToken` for refresh tokens too. -// -// This method adds the ExpectRefreshToken expectation and it -// tries to read the refresh token from raw body or, -// if content type was application/json, then it extracts the token -// from the JSON request body's {"refresh_token": "$token"} key. -func (j *JWT) VerifyRefreshToken(ctx *context.Context, claimsPtr interface{}, expectations ...Expectation) (*TokenInfo, error) { - token := j.RequestToken(ctx) - if token == "" { - ctx.RecordRequestBody(true) - defer ctx.RecordRequestBody(false) - - var tokenPair TokenPair // read "refresh_token" from JSON. - if ctx.GetContentTypeRequested() == context.ContentJSONHeaderValue { - ctx.ReadJSON(&tokenPair) // ignore error. - token = tokenPair.RefreshToken - if token == "" { - return nil, ErrMissing - } - } else { - ctx.ReadBody(&token) - } - } - - return j.VerifyTokenString(ctx, token, claimsPtr, append(expectations, ExpectRefreshToken)...) -} - -// RequestToken extracts the token from the request. -func (j *JWT) RequestToken(ctx *context.Context) (token string) { - for _, extract := range j.Extractors { - if token = extract(ctx); token != "" { - break // ok we found it. - } - } - - return -} - -// TokenSetter is an interface which if implemented -// the extracted, verified, token is stored to the object. -type TokenSetter interface { - SetToken(token string) -} - -// TokenInfo holds the standard token information may required -// for further actions. -// This structure is mostly useful when the developer's go structure -// does not hold the standard jwt fields (e.g. "exp") -// but want access to the parsed token which contains those fields. -// Inside the middleware, it is used to invalidate tokens through server-side, see `Invalidate`. -type TokenInfo struct { - RequestToken string // The request token. - Claims Claims // The standard JWT parsed fields from the request Token. - Value interface{} // The pointer to the end-developer's custom claims structure (see `Get`). -} - -const tokenInfoContextKey = "iris.jwt.token" - -// Get returns the verified developer token claims. -// -// -// Usage: -// j := jwt.New(...) -// app.Use(j.Verify(func() interface{} { return new(CustomClaims) })) -// app.Post("/restricted", func(ctx iris.Context){ -// claims := jwt.Get(ctx).(*CustomClaims) -// [use claims...] -// }) -// -// Note that there is one exception, if the value was a pointer -// to a map[string]interface{}, it returns the map itself so it can be -// accessible directly without the requirement of unwrapping it, e.g. -// j.Verify(func() interface{} { -// return &iris.Map{} -// } -// [...] -// claims := jwt.Get(ctx).(iris.Map) -func Get(ctx *context.Context) interface{} { - if tok := GetTokenInfo(ctx); tok != nil { - switch v := tok.Value.(type) { - case *context.Map: - return *v - case *json.RawMessage: - // This is useful when we can accept more than one - // type of JWT token in the same request path, - // but we also want to keep type safety. - // Usage: - // type myClaims struct { Roles []string `json:"roles"`} - // v := jwt.Get(ctx) - // var claims myClaims - // jwt.Unmarshal(v, &claims) - // [...claims.Roles] - return *v - default: - return v - } - } - - return nil -} - -// GetTokenInfo returns the verified token's information. -func GetTokenInfo(ctx *context.Context) *TokenInfo { - if v := ctx.Values().Get(tokenInfoContextKey); v != nil { - if t, ok := v.(*TokenInfo); ok { - return t - } - } - - return nil -} - -// Invalidate invalidates a verified JWT token. -// It adds the request token, retrieved by Verify methods, to the block list. -// Next request will be blocked, even if the token was not yet expired. -// This method can be used when the client-side does not clear the token -// on a user logout operation. -// -// Note: the Blocklist should be initialized before serve-time: j.InitDefaultBlocklist(). -func (j *JWT) Invalidate(ctx *context.Context) { - if j.Blocklist == nil { - ctx.Application().Logger().Debug("jwt.Invalidate: Blocklist is nil") - return - } - - tokenInfo := GetTokenInfo(ctx) - if tokenInfo == nil { - return - } - - j.Blocklist.Set(tokenInfo.RequestToken, tokenInfo.Claims.Expiry.Time()) -} - -// VerifyTokenString verifies and unmarshals an extracted request token to "dest" destination. -// The last variadic input indicates any further validations against the verified token claims. -// If the given "dest" is a valid context.User then ctx.User() will return it. -// If the token is missing an `ErrMissing` is returned. -// If the incoming token was expired an `ErrExpired` is returned. -// If the incoming token was blocked by the server an `ErrBlocked` is returned. -func (j *JWT) VerifyTokenString(ctx *context.Context, token string, dest interface{}, expectations ...Expectation) (*TokenInfo, error) { - if token == "" { - return nil, ErrMissing - } - - var ( - parsedToken *jwt.JSONWebToken - err error - ) - - if j.DecriptionKey != nil { - t, cerr := jwt.ParseSignedAndEncrypted(token) - if cerr != nil { - return nil, cerr - } - - parsedToken, err = t.Decrypt(j.DecriptionKey) - } else { - parsedToken, err = jwt.ParseSigned(token) - } - if err != nil { - return nil, err - } - - var ( - claims Claims - tokenMaxAger tokenWithMaxAge - ) - - var ( - ignoreDest = dest == nil - ignoreVarClaims bool - ) - if !ignoreDest { // if dest was not nil, check if the dest is already a standard claims pointer. - _, ignoreVarClaims = dest.(*Claims) - } - - // Ensure read the standard claims one if dest was Claims or was nil. - // (it wont break anything if we unmarshal them twice though, we just do it for performance reasons). - var pointers = []interface{}{&tokenMaxAger} - if !ignoreDest { - pointers = append(pointers, dest) - } - if !ignoreVarClaims { - pointers = append(pointers, &claims) - } - if err = parsedToken.Claims(j.VerificationKey, pointers...); err != nil { - return nil, err - } - - // Set the std claims, if missing from receiver so the expectations and validation still work. - if ignoreVarClaims { - claims = *dest.(*Claims) - } else if ignoreDest { - dest = &claims - } - - expectMaxAge := j.MaxAge - - // Build the Expected value. - expected := Expected{} - for _, e := range expectations { - if e != nil { - // expection can be used as a field validation too (see MeetRequirements). - if err = e(&expected, dest); err != nil { - if err == ErrExpectRefreshToken { - if tokenMaxAger.MaxAge > 0 { - // If max age exists, grab it and compare it later. - // Otherwise fire the ErrExpectRefreshToken. - expectMaxAge = tokenMaxAger.MaxAge - continue - } - } - return nil, err - } - } - } - - gotMaxAge := getMaxAge(claims) - if !compareMaxAge(expectMaxAge, gotMaxAge) { - // Additional check to automatically invalidate - // any previous jwt maxAge setting change. - // In-short, if the time.Now().Add j.MaxAge - // does not match the "iat" (issued at) then we invalidate the token. - return nil, ErrInvalidMaxAge - } - - // For other standard JWT claims fields such as "exp" - // The developer can just add a field of Expiry *NumericDate `json:"exp"` - // and will be filled by the parsed token automatically. - // No need for more interfaces. - - err = validateClaims(ctx, dest, claims, expected) - if err != nil { - if err == ErrExpired { - // If token was expired remove it from the block list. - if j.Blocklist != nil { - j.Blocklist.Del(token) - } - } - - return nil, err - } - - if j.Blocklist != nil { - // If token exists in the block list, then stop here. - if j.Blocklist.Has(token) { - return nil, ErrBlocked - } - } - - if !ignoreDest { - if ut, ok := dest.(TokenSetter); ok { - // The u.Token is empty even if we set it and export it on JSON structure. - // Set it manually. - ut.SetToken(token) - } - } - - // Set the information. - tokenInfo := &TokenInfo{ - RequestToken: token, - Claims: claims, - Value: dest, - } - - return tokenInfo, nil -} - -// TokenPair holds the access token and refresh token response. -type TokenPair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -type tokenWithMaxAge struct { - // Useful to separate access from refresh tokens. - // Can be used to by-pass the internal check of expected - // MaxAge setting to match the token's received max age too. - MaxAge time.Duration `json:"tokenMaxAge"` -} - -// TokenPair generates a token pair of access and refresh tokens. -// The first two arguments required for the refresh token -// and the last one is the claims for the access token one. -func (j *JWT) TokenPair(refreshMaxAge time.Duration, refreshClaims interface{}, accessClaims interface{}) (TokenPair, error) { - if refreshMaxAge <= j.MaxAge { - return TokenPair{}, fmt.Errorf("refresh max age should be bigger than access token's one[%d - %d]", refreshMaxAge, j.MaxAge) - } - - accessToken, err := j.Token(accessClaims) - if err != nil { - return TokenPair{}, err - } - - c, err := normalize(refreshClaims) - if err != nil { - return TokenPair{}, err - } - if c == nil { - c = make(context.Map) - } - // need to validate against its value instead of the setting's one (see `VerifyTokenString`). - c["tokenMaxAge"] = refreshMaxAge - - refreshToken, err := j.token(refreshMaxAge, c) - if err != nil { - return TokenPair{}, nil - } - - pair := TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - } - - return pair, nil -} - -// Verify returns a middleware which -// decrypts an incoming request token to the result of the given "newPtr". -// It does write a 401 unauthorized status code if verification or decryption failed. -// It calls the `ctx.Next` on verified requests. -// -// Iit unmarshals the token to the specific type returned from the given "newPtr" function. -// It sets the Context User and User's Token too. So the next handler(s) -// of the same chain can access the User through a `Context.User()` call. -// -// Note unlike `VerifyToken`, this method automatically protects -// the claims with JSON required tags (see `MeetRequirements` Expection). -// -// On verified tokens: -// - The information can be retrieved through `Get` and `GetTokenInfo` functions. -// - User is set if the newPtr returns a valid Context User -// - The Context Logout method is set if Blocklist was initialized -// Any error is captured to the Context, -// which can be retrieved by a `ctx.GetErr()` call. -// -// See `VerifyJSON` too. -func (j *JWT) Verify(newPtr func() interface{}, expections ...Expectation) context.Handler { - if newPtr == nil { - newPtr = func() interface{} { - // Return a map here as the default type one, - // as it does allow .Get callers to access its fields with ease - // (although, I always recommend using structs for type-safety and - // also they can accept a required tag option too). - return &context.Map{} - } - } - - expections = append(expections, MeetRequirements(newPtr())) - - return func(ctx *context.Context) { - ptr := newPtr() - - tokenInfo, err := j.VerifyToken(ctx, ptr, expections...) - if err != nil { - ctx.Application().Logger().Debugf("iris.jwt.Verify: %v", err) - ctx.StopWithError(401, context.PrivateError(err)) - return - } - - if u, ok := ptr.(context.User); ok { - ctx.SetUser(u) - } - - if j.Blocklist != nil { - ctx.SetLogoutFunc(j.Invalidate) - } - - ctx.Values().Set(tokenInfoContextKey, tokenInfo) - ctx.Next() - } -} - -// VerifyMap is a shortcut of Verify with a function which will bind -// the claims to a standard Go map[string]interface{}. -func (j *JWT) VerifyMap(expections ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return &context.Map{} - }, expections...) -} - -// VerifyJSON works like `Verify` but instead it -// binds its "newPtr" function to return a raw JSON message. -// It does NOT read the token from JSON by itself, -// to do that add the `FromJSON` to the Token Extractors. -// It's used to bind the claims in any value type on the next handler. -// -// This allows the caller to bind this JSON message to any Go structure (or map). -// This is useful when we can accept more than one -// type of JWT token in the same request path, -// but we also want to keep type safety. -// Usage: -// app.Use(jwt.VerifyJSON()) -// Inside a route Handler: -// claims := struct { Roles []string `json:"roles"`}{} -// jwt.ReadJSON(ctx, &claims) -// ...access to claims.Roles as []string -func (j *JWT) VerifyJSON(expections ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return new(json.RawMessage) - }, expections...) -} - -// ReadJSON is a helper which binds "claimsPtr" to the -// raw JSON token claims. -// Use inside the handlers when `VerifyJSON()` middleware was registered. -func ReadJSON(ctx *context.Context, claimsPtr interface{}) error { - v := Get(ctx) - if v == nil { - return ErrMissing - } - data, ok := v.(json.RawMessage) - if !ok { - return ErrMissing - } - return Unmarshal(data, claimsPtr) -} - -// NewUser returns a new User based on the given "opts". -// The caller can modify the User until its `GetToken` is called. -func (j *JWT) NewUser(opts ...UserOption) *User { - u := &User{ - j: j, - SimpleUser: &context.SimpleUser{ - Authorization: "IRIS_JWT_USER", // Used to separate a refresh token with a user/access one too. - Features: []context.UserFeature{ - context.TokenFeature, - }, - }, - } - - for _, opt := range opts { - opt(u) - } - - return u -} - -// VerifyUser works like the `Verify` method but instead -// it unmarshals the token to the specific User type. -// It sets the Context User too. So the next handler(s) -// of the same chain can access the User through a `Context.User()` call. -func (j *JWT) VerifyUser(expectations ...Expectation) context.Handler { - return j.Verify(func() interface{} { - return new(User) - }, expectations...) -} diff --git a/middleware/jwt/jwt_test.go b/middleware/jwt/jwt_test.go index 277ce7bb27..b65c02b7ed 100644 --- a/middleware/jwt/jwt_test.go +++ b/middleware/jwt/jwt_test.go @@ -1,8 +1,7 @@ -// Package jwt_test contains simple Iris jwt tests. Most of the jwt functionality is already tested inside the jose package itself. package jwt_test import ( - "os" + "fmt" "testing" "time" @@ -11,324 +10,56 @@ import ( "github.com/kataras/iris/v12/middleware/jwt" ) -type userClaims struct { - // Optionally: - Issuer string `json:"iss"` - Subject string `json:"sub"` - Audience jwt.Audience `json:"aud"` - // - Username string `json:"username"` -} - -const testMaxAge = 7 * time.Second - -// Random RSA verification and encryption. -func TestRSA(t *testing.T) { - j := jwt.RSA(testMaxAge) - t.Cleanup(func() { - os.Remove(jwt.DefaultSignFilename) - os.Remove(jwt.DefaultEncFilename) - }) - testWriteVerifyBlockToken(t, j) -} - -// HMAC verification and encryption. -func TestHMAC(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - testWriteVerifyBlockToken(t, j) -} - -func TestNew_HMAC(t *testing.T) { - j, err := jwt.New(testMaxAge, jwt.HS256, []byte("secret")) - if err != nil { - t.Fatal(err) - } - err = j.WithEncryption(jwt.A128GCM, jwt.DIRECT, []byte("itsa16bytesecret")) - if err != nil { - t.Fatal(err) - } - - testWriteVerifyBlockToken(t, j) -} - -// HMAC verification only (unecrypted). -func TestVerify(t *testing.T) { - j, err := jwt.New(testMaxAge, jwt.HS256, []byte("another secret")) - if err != nil { - t.Fatal(err) - } - testWriteVerifyBlockToken(t, j) -} - -func testWriteVerifyBlockToken(t *testing.T, j *jwt.JWT) { - t.Helper() - - j.InitDefaultBlocklist() - j.Extractors = append(j.Extractors, jwt.FromJSON("access_token")) - - customClaims := &userClaims{ - Issuer: "an-issuer", - Audience: jwt.Audience{"an-audience"}, - Subject: "user", - Username: "kataras", - } - - app := iris.New() - app.OnErrorCode(iris.StatusUnauthorized, func(ctx iris.Context) { - if err := ctx.GetErr(); err != nil { - // Test accessing the private error and set this as the response body. - ctx.WriteString(err.Error()) - } else { // Else the default behavior - ctx.WriteString(iris.StatusText(iris.StatusUnauthorized)) - } - }) - - app.Get("/auth", func(ctx iris.Context) { - j.WriteToken(ctx, customClaims) - }) - - app.Post("/protected", func(ctx iris.Context) { - var claims userClaims - _, err := j.VerifyToken(ctx, &claims) - if err != nil { - // t.Logf("%s: %v", ctx.Path(), err) - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - - ctx.JSON(claims) - }) - - m := app.Party("/middleware") - m.Use(j.Verify(func() interface{} { - return new(userClaims) - })) - m.Post("/protected", func(ctx iris.Context) { - claims := jwt.Get(ctx) - ctx.JSON(claims) - }) - m.Post("/invalidate", func(ctx iris.Context) { - ctx.Logout() // OR j.Invalidate(ctx) - }) - - e := httptest.New(t, app) - - // Get token. - rawToken := e.GET("/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if rawToken == "" { - t.Fatalf("empty token") - } - - restrictedPaths := [...]string{"/protected", "/middleware/protected"} - - now := time.Now() - for _, path := range restrictedPaths { - // Authorization Header. - e.POST(path).WithHeader("Authorization", "Bearer "+rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // URL Query. - e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // JSON Body. - e.POST(path).WithJSON(iris.Map{"access_token": rawToken}).Expect(). - Status(httptest.StatusOK).JSON().Equal(customClaims) - - // Missing "Bearer". - e.POST(path).WithHeader("Authorization", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is missing") - } - - // Invalidate the token. - e.POST("/middleware/invalidate").WithQuery("token", rawToken).Expect(). - Status(httptest.StatusOK) - // Token is blocked by server. - e.POST("/middleware/protected").WithQuery("token", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is blocked") - - expireRemDur := testMaxAge - time.Since(now) - - // Expiration. - time.Sleep(expireRemDur /* -end */) - for _, path := range restrictedPaths { - e.POST(path).WithQuery("token", rawToken).Expect(). - Status(httptest.StatusUnauthorized).Body().Equal("token is expired (exp)") - } -} - -func TestVerifyMap(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - expectedClaims := iris.Map{ - "iss": "tester", - "username": "makis", - "roles": []string{"admin"}, - } - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, expectedClaims) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - - if expectedClaims["exp"] == nil || expectedClaims["iat"] == nil { - ctx.StopWithText(iris.StatusBadRequest, - "exp or/and iat is nil - this means that the expiry was not set") - return - } - }) - - userAPI := app.Party("/user") - userAPI.Post("/", func(ctx iris.Context) { - var claims iris.Map - if _, err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - - ctx.JSON(claims) - }) - - // Test map + Verify middleware. - userAPI.Post("/middleware", j.Verify(nil), func(ctx iris.Context) { - claims := jwt.Get(ctx) - ctx.JSON(claims) - }) - - e := httptest.New(t, app, httptest.LogLevel("error")) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } +var testAlg, testSecret = jwt.HS256, []byte("sercrethatmaycontainch@r$") - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - e.POST("/user/middleware").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedClaims) - - e.POST("/user").Expect().Status(httptest.StatusUnauthorized) -} - -type customClaims struct { - Username string `json:"username"` - Token string `json:"token"` +type fooClaims struct { + Foo string `json:"foo"` } -func (c *customClaims) SetToken(tok string) { - c.Token = tok -} - -func TestVerifyStruct(t *testing.T) { - maxAge := testMaxAge / 2 - j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") - +// 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("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, customClaims{ - Username: "makis", - }) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - }) - - userAPI := app.Party("/user") - userAPI.Post("/", func(ctx iris.Context) { - var claims customClaims - if _, err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithError(iris.StatusUnauthorized, iris.PrivateError(err)) - return - } - ctx.JSON(claims) - }) - - e := httptest.New(t, app) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Object().ContainsMap(iris.Map{ - "username": "makis", - "token": token, // Test SetToken. - }) - - e.POST("/user").Expect().Status(httptest.StatusUnauthorized) - time.Sleep(maxAge) - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect().Status(httptest.StatusUnauthorized) -} - -func TestVerifyJSON(t *testing.T) { - j := jwt.HMAC(testMaxAge, "secret", "itsa16bytesecret") - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - err := j.WriteToken(ctx, iris.Map{"roles": []string{"admin"}}) - if err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return - } - }) - - app.Post("/", j.VerifyJSON(), func(ctx iris.Context) { - claims := struct { - Roles []string `json:"roles"` - }{} - jwt.ReadJSON(ctx, &claims) - ctx.JSON(claims) - }) - - e := httptest.New(t, app, httptest.LogLevel("error")) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - - e.POST("/").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(iris.Map{"roles": []string{"admin"}}) - - e.POST("/").Expect().Status(httptest.StatusUnauthorized) -} - -func TestVerifyUserAndExpected(t *testing.T) { // Tests the jwt.User struct + context validator + expected. - maxAge := testMaxAge / 2 - j := jwt.HMAC(maxAge, "secret", "itsa16bytesecret") - expectedUser := j.NewUser(jwt.Username("makis"), jwt.Roles("admin"), jwt.Fields(iris.Map{ - "custom": true, - })) // only for the sake of the test, we iniitalize it here. - expectedUser.Issuer = "tester" - - app := iris.New() - app.Get("/user/auth", func(ctx iris.Context) { - tok, err := expectedUser.GetToken() + signer := jwt.NewSigner(testAlg, testSecret, 3*time.Second) + app.Get("/", func(ctx iris.Context) { + claims := fooClaims{Foo: "bar"} + token, err := signer.Sign(claims) if err != nil { ctx.StopWithError(iris.StatusInternalServerError, err) return } - ctx.WriteString(tok) + ctx.Write(token) }) - userAPI := app.Party("/user") - userAPI.Use(jwt.WithExpected(jwt.Expected{Issuer: "tester"}, j.VerifyUser())) - userAPI.Post("/", func(ctx iris.Context) { - user := ctx.User() - ctx.JSON(user) + verifier := jwt.NewVerifier(testAlg, testSecret) + verifier.ErrorHandler = func(ctx iris.Context, err error) { // app.OnErrorCode(401, ...) + ctx.StopWithError(iris.StatusUnauthorized, err) + } + middleware := verifier.Verify(func() interface{} { return new(fooClaims) }) + app.Get("/protected", middleware, func(ctx iris.Context) { + claims := jwt.Get(ctx).(*fooClaims) + ctx.WriteString(claims.Foo) }) e := httptest.New(t, app) - token := e.GET("/user/auth").Expect().Status(httptest.StatusOK).Body().Raw() - if token == "" { - t.Fatalf("empty token") - } - - e.POST("/user").WithHeader("Authorization", "Bearer "+token).Expect(). - Status(httptest.StatusOK).JSON().Equal(expectedUser) - // Test generic client message if we don't manage the private error by ourselves. - e.POST("/user").Expect().Status(httptest.StatusUnauthorized).Body().Equal("Unauthorized") + // 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 f40f69ab3c..0000000000 --- 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 0000000000..c2243d0a0a --- /dev/null +++ b/middleware/jwt/signer.go @@ -0,0 +1,59 @@ +package jwt + +import ( + "fmt" + "time" + + "github.com/kataras/jwt" +) + +type Signer struct { + Alg Alg + Key interface{} + + MaxAge time.Duration + + Encrypt func([]byte) ([]byte, error) +} + +func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) *Signer { + return &Signer{ + Alg: signatureAlg, + Key: signatureKey, + MaxAge: maxAge, + } +} + +// WithGCM enables AES-GCM payload decryption. +func (s *Signer) WithGCM(key, additionalData []byte) *Signer { + encrypt, _, err := jwt.GCM(key, additionalData) + if err != nil { + panic(err) // important error before serve, stop everything. + } + + s.Encrypt = encrypt + return s +} + +func (s *Signer) Sign(claims interface{}, opts ...SignOption) ([]byte, error) { + return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, append([]SignOption{MaxAge(s.MaxAge)}, opts...)...) +} + +func (s *Signer) NewTokenPair(accessClaims interface{}, refreshClaims interface{}, refreshMaxAge time.Duration, accessOpts ...SignOption) (TokenPair, error) { + if refreshMaxAge <= s.MaxAge { + return TokenPair{}, fmt.Errorf("refresh max age should be bigger than access token's one[%d - %d]", refreshMaxAge, s.MaxAge) + } + + accessToken, err := s.Sign(accessClaims, accessOpts...) + if err != nil { + return TokenPair{}, err + } + + refreshToken, err := Sign(s.Alg, s.Key, refreshClaims, MaxAge(refreshMaxAge)) + if err != nil { + return TokenPair{}, err + } + + tokenPair := jwt.NewTokenPair(accessToken, refreshToken) + return tokenPair, nil +} diff --git a/middleware/jwt/user.go b/middleware/jwt/user.go deleted file mode 100644 index 3d8af27f27..0000000000 --- a/middleware/jwt/user.go +++ /dev/null @@ -1,187 +0,0 @@ -package jwt - -import ( - "time" - - "github.com/kataras/iris/v12/context" -) - -// User a common User structure for JWT. -// However, we're not limited to that one; -// any Go structure can be generated as a JWT token. -// -// Look `NewUser` and `VerifyUser` JWT middleware's methods. -// Use its `GetToken` method to generate the token when -// the User structure is set. -type User struct { - Claims - // Note: we could use a map too as the Token is generated when GetToken is called. - *context.SimpleUser - - j *JWT -} - -var ( - _ context.FeaturedUser = (*User)(nil) - _ TokenSetter = (*User)(nil) - _ ContextValidator = (*User)(nil) -) - -// UserOption sets optional fields for a new User -// See `NewUser` instance function. -type UserOption func(*User) - -// Username sets the Username and the JWT Claim's Subject -// to the given "username". -func Username(username string) UserOption { - return func(u *User) { - u.Username = username - u.Claims.Subject = username - u.Features = append(u.Features, context.UsernameFeature) - } -} - -// Email sets the Email field for the User field. -func Email(email string) UserOption { - return func(u *User) { - u.Email = email - u.Features = append(u.Features, context.EmailFeature) - } -} - -// Roles upserts to the User's Roles field. -func Roles(roles ...string) UserOption { - return func(u *User) { - u.Roles = roles - u.Features = append(u.Features, context.RolesFeature) - } -} - -// MaxAge sets claims expiration and the AuthorizedAt User field. -func MaxAge(maxAge time.Duration) UserOption { - return func(u *User) { - now := time.Now() - u.Claims.Expiry = NewNumericDate(now.Add(maxAge)) - u.Claims.IssuedAt = NewNumericDate(now) - u.AuthorizedAt = now - - u.Features = append(u.Features, context.AuthorizedAtFeature) - } -} - -// Fields copies the "fields" to the user's Fields field. -// This can be used to set custom fields to the User instance. -func Fields(fields context.Map) UserOption { - return func(u *User) { - if len(fields) == 0 { - return - } - - if u.Fields == nil { - u.Fields = make(context.Map, len(fields)) - } - - for k, v := range fields { - u.Fields[k] = v - } - - u.Features = append(u.Features, context.FieldsFeature) - } -} - -// SetToken is called automaticaly on VerifyUser/VerifyObject. -// It sets the extracted from request, and verified from server raw token. -func (u *User) SetToken(token string) { - u.Token = token -} - -// GetToken overrides the SimpleUser's Token -// and returns the jwt generated token, among with -// a generator error, if any. -func (u *User) GetToken() (string, error) { - if u.Token != "" { - return u.Token, nil - } - - if u.j != nil { // it's always not nil. - if u.j.MaxAge > 0 { - // if the MaxAge option was not manually set, resolve it from the JWT instance. - MaxAge(u.j.MaxAge)(u) - } - - // we could generate a token here - // but let's do it on GetToken - // as the user fields may change - // by the caller manually until the token - // sent to the client. - tok, err := u.j.Token(u) - if err != nil { - return "", err - } - - u.Token = tok - } - - if u.Token == "" { - return "", ErrMissing - } - - return u.Token, nil -} - -// Validate validates the current user's claims against -// the request. It's called automatically by the JWT instance. -func (u *User) Validate(ctx *context.Context, claims Claims, e Expected) error { - err := u.Claims.ValidateWithLeeway(e, 0) - if err != nil { - return err - } - - if u.SimpleUser.Authorization != "IRIS_JWT_USER" { - return ErrInvalidKey - } - - // We could add specific User Expectations (new struct and accept an interface{}), - // but for the sake of code simplicity we don't, unless is requested, as the caller - // can validate specific fields by its own at the next step. - return nil -} - -// UnmarshalJSON implements the json unmarshaler interface. -func (u *User) UnmarshalJSON(data []byte) error { - err := Unmarshal(data, &u.Claims) - if err != nil { - return err - } - simpleUser := new(context.SimpleUser) - err = Unmarshal(data, simpleUser) - if err != nil { - return err - } - u.SimpleUser = simpleUser - return nil -} - -// MarshalJSON implements the json marshaler interface. -func (u *User) MarshalJSON() ([]byte, error) { - claimsB, err := Marshal(u.Claims) - if err != nil { - return nil, err - } - - userB, err := Marshal(u.SimpleUser) - if err != nil { - return nil, err - } - - if len(userB) == 0 { - return claimsB, nil - } - - claimsB = claimsB[0 : len(claimsB)-1] // remove last '}' - userB = userB[1:] // remove first '{' - - raw := append(claimsB, ',') - raw = append(raw, userB...) - return raw, nil -} diff --git a/middleware/jwt/validation.go b/middleware/jwt/validation.go deleted file mode 100644 index 9b5b69642f..0000000000 --- a/middleware/jwt/validation.go +++ /dev/null @@ -1,258 +0,0 @@ -package jwt - -import ( - "bytes" - "errors" - "reflect" - "strings" - "time" - - "github.com/kataras/iris/v12/context" - - "github.com/square/go-jose/v3/json" - // Use this package instead of the standard encoding/json - // to marshal the NumericDate as expected by the implementation (see 'normalize`). - "github.com/square/go-jose/v3/jwt" -) - -const ( - claimsExpectedContextKey = "iris.jwt.claims.expected" - needsValidationContextKey = "iris.jwt.claims.unvalidated" -) - -var ( - // ErrMissing when token cannot be extracted from the request (custm error). - ErrMissing = errors.New("token is missing") - // ErrMissingKey when token does not contain a required JSON field (custom error). - ErrMissingKey = errors.New("token is missing a required field") - // ErrExpired indicates that token is used after expiry time indicated in exp claim. - ErrExpired = errors.New("token is expired (exp)") - // ErrNotValidYet indicates that token is used before time indicated in nbf claim. - ErrNotValidYet = errors.New("token not valid yet (nbf)") - // ErrIssuedInTheFuture indicates that the iat field is in the future. - ErrIssuedInTheFuture = errors.New("token issued in the future (iat)") - // ErrBlocked indicates that the token was not yet expired - // but was blocked by the server's Blocklist (custom error). - ErrBlocked = errors.New("token is blocked") - // ErrInvalidMaxAge indicates that the token is using a different - // max age than the configurated one ( custom error). - ErrInvalidMaxAge = errors.New("token contains invalid max age") - // ErrExpectRefreshToken indicates that the retrieved token - // was not a refresh token one when `ExpectRefreshToken` is set (custome rror). - ErrExpectRefreshToken = errors.New("expect refresh token") -) - -// Expectation option to provide -// an extra layer of token validation, a claims type protection. -// See `VerifyToken` method. -type Expectation func(e *Expected, claims interface{}) error - -// Expect protects the claims with the expected values. -func Expect(expected Expected) Expectation { - return func(e *Expected, _ interface{}) error { - *e = expected - return nil - } -} - -// ExpectID protects the claims with an ID validation. -func ExpectID(id string) Expectation { - return func(e *Expected, _ interface{}) error { - e.ID = id - return nil - } -} - -// ExpectIssuer protects the claims with an issuer validation. -func ExpectIssuer(issuer string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Issuer = issuer - return nil - } -} - -// ExpectSubject protects the claims with a subject validation. -func ExpectSubject(sub string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Subject = sub - return nil - } -} - -// ExpectAudience protects the claims with an audience validation. -func ExpectAudience(audience ...string) Expectation { - return func(e *Expected, _ interface{}) error { - e.Audience = audience - return nil - } -} - -// ExpectRefreshToken SHOULD be passed when a token should be verified -// based on the expiration set by `TokenPair` method instead of the JWT instance's MaxAge setting. -// Useful to validate Refresh Tokens and invalidate Access ones when refresh API is fired, -// if that option is missing then refresh tokens are invalidated when an access token was expected. -// -// Usage: -// var refreshClaims jwt.Claims -// _, err := j.VerifyTokenString(ctx, tokenPair.RefreshToken, &refreshClaims, jwt.ExpectRefreshToken) -func ExpectRefreshToken(e *Expected, _ interface{}) error { return ErrExpectRefreshToken } - -// MeetRequirements protects the custom fields of JWT claims -// based on the json:required tag; `json:"name,required"`. -// It accepts the value type. -// -// Usage: -// Verify/VerifyToken(... MeetRequirements(MyUser{})) -func MeetRequirements(claimsType interface{}) Expectation { - // pre-calculate if we need to use reflection at serve time to check for required fields, - // this can work as an alternative of expections for custom non-standard JWT fields. - requireFieldsIndexes := getRequiredFieldIndexes(claimsType) - - return func(e *Expected, claims interface{}) error { - if len(requireFieldsIndexes) > 0 { - val := reflect.Indirect(reflect.ValueOf(claims)) - for _, idx := range requireFieldsIndexes { - field := val.Field(idx) - if field.IsZero() { - return ErrMissingKey - } - } - } - - return nil - } -} - -// WithExpected is a middleware wrapper. It wraps a VerifyXXX middleware -// with expected claims fields protection. -// Usage: -// jwt.WithExpected(jwt.Expected{Issuer:"app"}, j.VerifyUser) -func WithExpected(e Expected, verifyHandler context.Handler) context.Handler { - return func(ctx *context.Context) { - ctx.Values().Set(claimsExpectedContextKey, e) - verifyHandler(ctx) - } -} - -// ContextValidator validates the object based on the given -// claims and the expected once. The end-developer -// can use this method for advanced validations based on the request Context. -type ContextValidator interface { - Validate(ctx *context.Context, claims Claims, e Expected) error -} - -func validateClaims(ctx *context.Context, dest interface{}, claims Claims, expected Expected) (err error) { - // Get any dynamic expectation set by prior middleware. - // See `WithExpected` middleware. - if v := ctx.Values().Get(claimsExpectedContextKey); v != nil { - if e, ok := v.(Expected); ok { - expected = e - } - } - // Force-set the time, it's important for expiration. - expected.Time = time.Now() - switch c := dest.(type) { - case Claims: - err = c.ValidateWithLeeway(expected, 0) - case ContextValidator: - err = c.Validate(ctx, claims, expected) - case *context.Map: - // if the dest is a map then set automatically the expiration settings here, - // so the caller can work further with it. - err = claims.ValidateWithLeeway(expected, 0) - if err == nil { - (*c)["exp"] = claims.Expiry - (*c)["iat"] = claims.IssuedAt - if claims.NotBefore != nil { - (*c)["nbf"] = claims.NotBefore - } - } - default: - err = claims.ValidateWithLeeway(expected, 0) - } - - if err != nil { - switch err { - case jwt.ErrExpired: - return ErrExpired - case jwt.ErrNotValidYet: - return ErrNotValidYet - case jwt.ErrIssuedInTheFuture: - return ErrIssuedInTheFuture - } - } - - return err -} - -func normalize(i interface{}) (context.Map, error) { - if m, ok := i.(context.Map); ok { - return m, nil - } - - m := make(context.Map) - - raw, err := json.Marshal(i) - if err != nil { - return nil, err - } - - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - - if err := d.Decode(&m); err != nil { - return nil, err - } - - return m, nil -} - -func getRequiredFieldIndexes(i interface{}) (v []int) { - val := reflect.Indirect(reflect.ValueOf(i)) - typ := val.Type() - if typ.Kind() != reflect.Struct { - return nil - } - - for i := 0; i < val.NumField(); i++ { - field := typ.Field(i) - // Note: for the sake of simplicity we don't lookup for nested objects (FieldByIndex), - // we could do that as we do in dependency injection feature but unless requirested we don't. - tag := field.Tag.Get("json") - if strings.Contains(tag, ",required") { - v = append(v, i) - } - } - - return -} - -// getMaxAge returns the result of expiry-issued at. -// Note that if in JWT MaxAge's was set to a value like: 3.5 seconds -// this will return 3 on token retreival. Of course this is not a problem -// in real world apps as they don't invalidate tokens in seconds -// based on a division result like 2/7. -func getMaxAge(claims Claims) time.Duration { - if issuedAt := claims.IssuedAt.Time(); !issuedAt.IsZero() { - gotMaxAge := claims.Expiry.Time().Sub(issuedAt) - return gotMaxAge - } - - return 0 -} - -func compareMaxAge(expected, got time.Duration) bool { - if expected == got { - return true - } - - // got is int64, maybe rounded, but the max age setting is precise, may be a float result - // e.g. the result of a division 2/7=3.5, - // try to validate by round of second so similar/or equal max age setting are considered valid. - min, max := expected-time.Second, expected+time.Second - if got < min || got > max { - return false - } - - return true -} diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go new file mode 100644 index 0000000000..7438c921e0 --- /dev/null +++ b/middleware/jwt/verifier.go @@ -0,0 +1,210 @@ +package jwt + +import ( + "reflect" + "time" + + "github.com/kataras/iris/v12/context" + + "github.com/kataras/jwt" +) + +const ( + claimsContextKey = "iris.jwt.claims" + verifiedTokenContextKey = "iris.jwt.token" +) + +// Get returns the claims decoded by a verifier. +func Get(ctx *context.Context) interface{} { + if v := ctx.Values().Get(claimsContextKey); v != nil { + return v + } + + return nil +} + +// GetVerifiedToken returns the verified token structure +// which holds information about the decoded token +// and its standard claims. +func GetVerifiedToken(ctx *context.Context) *VerifiedToken { + if v := ctx.Values().Get(verifiedTokenContextKey); v != nil { + if tok, ok := v.(*VerifiedToken); ok { + return tok + } + } + + return nil +} + +// Verifier holds common options to verify an incoming token. +// Its Verify method can be used as a middleware to allow authorized clients to access an API. +type Verifier struct { + Alg Alg + Key interface{} + + Decrypt func([]byte) ([]byte, error) + + Extractors []TokenExtractor + Blocklist Blocklist + Validators []TokenValidator + + ErrorHandler func(ctx *context.Context, err error) +} + +// NewVerifier accepts the algorithm for the token's signature among with its (private) key +// and optionally some token validators for all verify middlewares that may initialized under this Verifier. +// +// See its Verify method. +func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...TokenValidator) *Verifier { + return &Verifier{ + Alg: signatureAlg, + Key: signatureKey, + Extractors: []TokenExtractor{FromHeader, FromQuery}, + ErrorHandler: func(ctx *context.Context, err error) { + ctx.StopWithError(401, context.PrivateError(err)) + }, + Validators: validators, + } +} + +// WithGCM enables AES-GCM payload encryption. +func (v *Verifier) WithGCM(key, additionalData []byte) *Verifier { + _, decrypt, err := jwt.GCM(key, additionalData) + if err != nil { + panic(err) // important error before serve, stop everything. + } + + v.Decrypt = decrypt + return v +} + +// WithDefaultBlocklist attaches an in-memory blocklist storage +// to invalidate tokens through server-side. +// To invalidate a token simply call the Context.Logout method. +func (v *Verifier) WithDefaultBlocklist() *Verifier { + v.Blocklist = jwt.NewBlocklist(30 * time.Minute) + return v +} + +func (v *Verifier) invalidate(ctx *context.Context) { + if verifiedToken := GetVerifiedToken(ctx); verifiedToken != nil { + v.Blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims.Expiry) + ctx.Values().Remove(claimsContextKey) + ctx.Values().Remove(verifiedTokenContextKey) + ctx.SetUser(nil) + ctx.SetLogoutFunc(nil) + } +} + +// RequestToken extracts the token from the request. +func (v *Verifier) RequestToken(ctx *context.Context) (token string) { + for _, extract := range v.Extractors { + if token = extract(ctx); token != "" { + break // ok we found it. + } + } + + return +} + +type ( + // ClaimsValidator is a special interface which, if the destination claims + // implements it then the verifier runs its Validate method before return. + ClaimsValidator interface { + Validate() error + } + + // ClaimsContextValidator same as ClaimsValidator but it accepts + // a request context which can be used for further checks before + // validating the incoming token's claims. + ClaimsContextValidator interface { + Validate(*context.Context) error + } +) + +// VerifyToken simply verifies the given "token" and validates its standard claims (such as expiration). +// Returns a structure which holds the token's information. See the Verify method instead. +func (v *Verifier) VerifyToken(token []byte, validators ...TokenValidator) (*VerifiedToken, error) { + return jwt.VerifyEncrypted(v.Alg, v.Key, v.Decrypt, token, validators...) +} + +// Verify is the most important piece of code inside the Verifier. +// It accepts the "claimsType" function which should return a pointer to a custom structure +// which the token's decode claims valuee will be binded and validated to. +// Returns a common Iris handler which can be used as a middleware to protect an API +// from unauthorized client requests. After this, the route handlers can access the claims +// through the jwt.Get package-level function. +// +// Example Code: +func (v *Verifier) Verify(claimsType func() interface{}, validators ...TokenValidator) context.Handler { + unmarshal := jwt.Unmarshal + if claimsType != nil { + c := claimsType() + if hasRequired(c) { + unmarshal = jwt.UnmarshalWithRequired + } + } + + if v.Blocklist != nil { + validators = append([]TokenValidator{v.Blocklist}, append(v.Validators, validators...)...) + } + + return func(ctx *context.Context) { + token := []byte(v.RequestToken(ctx)) + verifiedToken, err := v.VerifyToken(token, validators...) + if err != nil { + v.ErrorHandler(ctx, err) + return + } + + if claimsType != nil { + dest := claimsType() + if err = unmarshal(verifiedToken.Payload, dest); err != nil { + v.ErrorHandler(ctx, err) + return + } + + if validator, ok := dest.(ClaimsValidator); ok { + if err = validator.Validate(); err != nil { + v.ErrorHandler(ctx, err) + return + } + } else if contextValidator, ok := dest.(ClaimsContextValidator); ok { + if err = contextValidator.Validate(ctx); err != nil { + v.ErrorHandler(ctx, err) + return + } + } + + if u, ok := dest.(context.User); ok { + ctx.SetUser(u) + } + + ctx.Values().Set(claimsContextKey, dest) + } + + if v.Blocklist != nil { + ctx.SetLogoutFunc(v.invalidate) + } + + ctx.Values().Set(verifiedTokenContextKey, verifiedToken) + ctx.Next() + } +} + +func hasRequired(i interface{}) bool { + val := reflect.Indirect(reflect.ValueOf(i)) + typ := val.Type() + if typ.Kind() != reflect.Struct { + return false + } + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + if jwt.HasRequiredJSONTag(field) { + return true + } + } + + return false +} From 3d59d19de61ecbb57c5d7230c3281f117886169b Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 31 Oct 2020 15:47:28 +0200 Subject: [PATCH 12/19] add context partial user helper and accept a generic interface on SetUser - the same method now returns an error if the given value does not complete at least one method of the User interface --- _examples/auth/basicauth/main.go | 4 +- _examples/auth/jwt/middleware/main.go | 4 +- _examples/auth/jwt/refresh-token/main.go | 26 +- context/context.go | 28 +- context/context_user.go | 457 ++++++++++++++--------- middleware/basicauth/basicauth_test.go | 6 +- middleware/jwt/signer.go | 8 +- middleware/jwt/verifier.go | 10 +- 8 files changed, 354 insertions(+), 189 deletions(-) diff --git a/_examples/auth/basicauth/main.go b/_examples/auth/basicauth/main.go index 80ecae42c3..0df1a5e557 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/middleware/main.go b/_examples/auth/jwt/middleware/main.go index 948a0c4c24..6af83e6420 100644 --- a/_examples/auth/jwt/middleware/main.go +++ b/_examples/auth/jwt/middleware/main.go @@ -26,14 +26,14 @@ func main() { signer := jwt.NewSigner(jwt.HS256, sigKey, 10*time.Minute) // Enable payload encryption with: - // signer.WithGCM(encKey, nil) + // 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.WithGCM(encKey, nil) + // verifier.WithDecryption(encKey, nil) verifyMiddleware := verifier.Verify(func() interface{} { return new(fooClaims) }) diff --git a/_examples/auth/jwt/refresh-token/main.go b/_examples/auth/jwt/refresh-token/main.go index cccf32667e..1e6ca58793 100644 --- a/_examples/auth/jwt/refresh-token/main.go +++ b/_examples/auth/jwt/refresh-token/main.go @@ -28,6 +28,21 @@ type UserClaims struct { Username string `json:"username"` } +// 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 +} + +// GetUsername implements the partial context user's Username interface. +func (u *UserClaims) GetUsername() string { + return u.Username +} + // Validate completes the middleware's custom ClaimsValidator. // It will not accept a token which its claims missing the username field // (useful to not accept refresh tokens generated by the same algorithm). @@ -58,8 +73,15 @@ func main() { protectedAPI.Use(verifyMiddleware) protectedAPI.Get("/", func(ctx iris.Context) { - claims := jwt.Get(ctx).(*UserClaims) - ctx.Writef("Username: %s\n", claims.Username) + // 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) }) } diff --git a/context/context.go b/context/context.go index 00edad58bd..7149801028 100644 --- a/context/context.go +++ b/context/context.go @@ -5340,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 1e27eb628c..893b86d606 100644 --- a/context/context_user.go +++ b/context/context_user.go @@ -1,6 +1,7 @@ package context import ( + "encoding/json" "errors" "strings" "time" @@ -27,30 +28,33 @@ var ErrNotSupported = errors.New("not supported") // To make optional some of the fields you can just embed the User interface // and implement whatever methods you want to support. // -// There are two builtin implementations of the User interface: -// - SimpleUser (type-safe) -// - UserMap (wraps a map[string]interface{}) +// 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() (string, error) + 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. @@ -63,6 +67,7 @@ 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 @@ -72,88 +77,51 @@ We kind of by-pass this disadvantage by providing a SimpleUser which can be embe to the end-developer's custom implementations. */ -// 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 -} - -// UserFeature a type which represents a user's optional feature. -// See `HasUserFeature` function for more. -type UserFeature uint32 - -// The list of standard UserFeatures. -const ( - AuthorizedAtFeature UserFeature = iota - UsernameFeature - PasswordFeature - EmailFeature - RolesFeature - TokenFeature - FieldsFeature -) - -// HasUserFeature reports whether the "u" User -// implements a specific "feature" User Feature. -// -// 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 - } - } - - return false, nil - } - - return false, 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,omitempty"` - Password string `json:"-"` - Email string `json:"email,omitempty"` - Roles []string `json:"roles,omitempty"` - Features []UserFeature `json:"features,omitempty"` - Token string `json:"token,omitempty"` - Fields Map `json:"fields,omitempty"` + 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 _ FeaturedUser = (*SimpleUser)(nil) +var _ User = (*SimpleUser)(nil) // GetAuthorization returns the authorization method, // e.g. Basic Authentication. -func (u *SimpleUser) GetAuthorization() string { - return u.Authorization +func (u *SimpleUser) GetAuthorization() (string, error) { + return u.Authorization, nil } // GetAuthorizedAt returns the exact time the // client has been authorized for the "first" time. -func (u *SimpleUser) GetAuthorizedAt() time.Time { - return u.AuthorizedAt +func (u *SimpleUser) GetAuthorizedAt() (time.Time, error) { + return u.AuthorizedAt, nil +} + +// GetID returns the ID of the User. +func (u *SimpleUser) GetID() (string, error) { + return u.ID, nil } // GetUsername returns the name of the User. -func (u *SimpleUser) GetUsername() string { - return u.Username +func (u *SimpleUser) GetUsername() (string, error) { + return u.Username, nil } // GetPassword returns the raw password of the User. -func (u *SimpleUser) GetPassword() string { - return u.Password +func (u *SimpleUser) GetPassword() (string, error) { + return u.Password, nil } -// GetEmail returns the e-mail of the User. -func (u *SimpleUser) GetEmail() string { - return u.Email +// 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. @@ -171,9 +139,9 @@ func (u *SimpleUser) GetRoles() ([]string, error) { // // The implementation can change that behavior. // Returns with `ErrNotSupported` if the Token field is empty. -func (u *SimpleUser) GetToken() (string, error) { - if u.Token == "" { - return "", ErrNotSupported +func (u *SimpleUser) GetToken() ([]byte, error) { + if len(u.Token) == 0 { + return nil, ErrNotSupported } return u.Token, nil @@ -189,184 +157,325 @@ func (u *SimpleUser) GetField(key string) (interface{}, error) { return u.Fields[key], nil } -// GetFeatures returns a list of features -// this User implementation offers. -func (u *SimpleUser) GetFeatures() []UserFeature { - if u.Features != nil { - return u.Features - } - - var features []UserFeature - - if !u.AuthorizedAt.IsZero() { - features = append(features, AuthorizedAtFeature) - } - - if u.Username != "" { - features = append(features, UsernameFeature) - } - - if u.Password != "" { - features = append(features, PasswordFeature) - } - - if u.Email != "" { - features = append(features, EmailFeature) - } - - if u.Roles != nil { - features = append(features, RolesFeature) - } - - if u.Fields != nil { - features = append(features, FieldsFeature) - } - - return features -} - // 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(UserMap(user)) +// ctx.SetUser(user) // OR -// user := UserMap{"key": "value",...} +// user := UserStruct{....} // ctx.SetUser(user) // [...] -// username := ctx.User().GetUsername() -// age := ctx.User().GetField("age").(int) +// 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 _ FeaturedUser = UserMap{} +var _ User = UserMap{} // GetAuthorization returns the authorization or Authorization value of the map. -func (u UserMap) GetAuthorization() string { +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 { +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 { +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 { +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 { +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) { - if s := u.strSlice("roles"); s != nil { - return s, nil + 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 nil, ErrNotSupported } -// GetToken returns the roles or Roles value of the map. -func (u UserMap) GetToken() (string, error) { - if s := u.str("token"); s != "" { - return s, nil +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 "", ErrNotSupported } -// 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) strSlice(key string) ([]string, error) { + if v := u.val(key); v != nil { + if s, ok := v.([]string); ok { + return s, nil + } + } + + return nil, ErrNotSupported } -// GetFeatures returns a list of features -// this map offers. -func (u UserMap) GetFeatures() []UserFeature { - if v := u.val("features"); v != nil { // if already contain features. - if features, ok := v.([]UserFeature); ok { - return features +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 } } - // else try to resolve from map values. - features := []UserFeature{FieldsFeature} + 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.GetAuthorizedAt().IsZero() { - features = append(features, AuthorizedAtFeature) + if u, ok := i.(userGetPassword); ok { + p.userGetPassword = u + containsAtLeastOneMethod = true } - if u.GetUsername() != "" { - features = append(features, UsernameFeature) + if u, ok := i.(userGetEmail); ok { + p.userGetEmail = u + containsAtLeastOneMethod = true } - if u.GetPassword() != "" { - features = append(features, PasswordFeature) + if u, ok := i.(userGetRoles); ok { + p.userGetRoles = u + containsAtLeastOneMethod = true } - if u.GetEmail() != "" { - features = append(features, EmailFeature) + if u, ok := i.(userGetToken); ok { + p.userGetToken = u + containsAtLeastOneMethod = true } - if roles, err := u.GetRoles(); err == nil && roles != nil { - features = append(features, RolesFeature) + if u, ok := i.(userGetField); ok { + p.userGetField = u + containsAtLeastOneMethod = true } - return features + if !containsAtLeastOneMethod { + return nil + } + + return p } -func (u UserMap) val(key string) interface{} { - isTitle := unicode.IsTitle(rune(key[0])) // if starts with uppercase. - if isTitle { - key = strings.ToLower(key) +// GetAuthorization should return the authorization method, +// e.g. Basic Authentication. +func (u *UserPartial) GetAuthorization() (string, error) { + if v := u.userGetAuthorization; v != nil { + return v.GetAuthorization(), nil } - return u[key] + return "", ErrNotSupported } -func (u UserMap) str(key string) string { - if v := u.val(key); v != nil { - if s, ok := v.(string); ok { - return s - } +// GetAuthorizedAt should return the exact time the +// client has been authorized for the "first" time. +func (u *UserPartial) GetAuthorizedAt() (time.Time, error) { + if v := u.userGetAuthorizedAt; v != nil { + return v.GetAuthorizedAt(), nil + } - // exists or not we don't care, if it's invalid type we don't fill it. + return time.Time{}, ErrNotSupported +} + +// 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 "" + return "", ErrNotSupported } -func (u UserMap) strSlice(key string) []string { - if v := u.val(key); v != nil { - if s, ok := v.([]string); ok { - return s - } +// 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 nil + return "", ErrNotSupported } -func (u UserMap) time(key string) time.Time { - if v := u.val(key); v != nil { - if t, ok := v.(time.Time); ok { - return t - } +// 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 } - return time.Time{} + return "", ErrNotSupported +} + +// 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 + } + + 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 + } + + 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 + } + + 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 nil, ErrNotSupported } diff --git a/middleware/basicauth/basicauth_test.go b/middleware/basicauth/basicauth_test.go index 5ff44ab1d9..2eff61dc99 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/signer.go b/middleware/jwt/signer.go index c2243d0a0a..18bf7e3ffa 100644 --- a/middleware/jwt/signer.go +++ b/middleware/jwt/signer.go @@ -24,8 +24,8 @@ func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) } } -// WithGCM enables AES-GCM payload decryption. -func (s *Signer) WithGCM(key, additionalData []byte) *Signer { +// WithEncryption enables AES-GCM payload 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. @@ -35,10 +35,14 @@ func (s *Signer) WithGCM(key, additionalData []byte) *Signer { 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) { return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, append([]SignOption{MaxAge(s.MaxAge)}, 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) diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go index 7438c921e0..f37c2a7f57 100644 --- a/middleware/jwt/verifier.go +++ b/middleware/jwt/verifier.go @@ -49,6 +49,8 @@ type Verifier struct { 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 (private) key @@ -67,8 +69,8 @@ func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...Token } } -// WithGCM enables AES-GCM payload encryption. -func (v *Verifier) WithGCM(key, additionalData []byte) *Verifier { +// WithDecryption enables AES-GCM payload 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. @@ -176,8 +178,8 @@ func (v *Verifier) Verify(claimsType func() interface{}, validators ...TokenVali } } - if u, ok := dest.(context.User); ok { - ctx.SetUser(u) + if !v.DisableContextUser { + ctx.SetUser(dest) } ctx.Values().Set(claimsContextKey, dest) From 836fb18c57309bccd170eeba64677100ed6b0bfc Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 1 Nov 2020 04:23:36 +0200 Subject: [PATCH 13/19] add some godoc to jwt --- middleware/jwt/signer.go | 15 ++++++++++++++- middleware/jwt/verifier.go | 39 ++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/middleware/jwt/signer.go b/middleware/jwt/signer.go index 18bf7e3ffa..acd74237e1 100644 --- a/middleware/jwt/signer.go +++ b/middleware/jwt/signer.go @@ -7,6 +7,11 @@ import ( "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{} @@ -16,6 +21,14 @@ type Signer struct { 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 { return &Signer{ Alg: signatureAlg, @@ -24,7 +37,7 @@ func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) } } -// WithEncryption enables AES-GCM payload decryption. +// WithEncryption enables AES-GCM payload-only decryption. func (s *Signer) WithEncryption(key, additionalData []byte) *Signer { encrypt, _, err := jwt.GCM(key, additionalData) if err != nil { diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go index f37c2a7f57..1d354ec92d 100644 --- a/middleware/jwt/verifier.go +++ b/middleware/jwt/verifier.go @@ -38,6 +38,8 @@ func GetVerifiedToken(ctx *context.Context) *VerifiedToken { // 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{} @@ -53,10 +55,30 @@ type Verifier struct { DisableContextUser bool } -// NewVerifier accepts the algorithm for the token's signature among with its (private) key +// 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 { return &Verifier{ Alg: signatureAlg, @@ -69,7 +91,7 @@ func NewVerifier(signatureAlg Alg, signatureKey interface{}, validators ...Token } } -// WithDecryption enables AES-GCM payload encryption. +// WithDecryption enables AES-GCM payload-only encryption. func (v *Verifier) WithDecryption(key, additionalData []byte) *Verifier { _, decrypt, err := jwt.GCM(key, additionalData) if err != nil { @@ -137,7 +159,16 @@ func (v *Verifier) VerifyToken(token []byte, validators ...TokenValidator) (*Ver // from unauthorized client requests. After this, the route handlers can access the claims // through the jwt.Get package-level function. // -// Example Code: +// 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 { From f1ebddb6d98630e5c9785201a50e3c3da92c73de Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Mon, 2 Nov 2020 06:31:28 +0200 Subject: [PATCH 14/19] jwt: add redis blocklist --- _examples/auth/jwt/blocklist/main.go | 110 ++++++++++++ go.mod | 2 +- middleware/jwt/alises.go | 101 ++++++----- middleware/jwt/blocklist.go | 15 +- middleware/jwt/blocklist/redis/blocklist.go | 188 ++++++++++++++++++++ middleware/jwt/signer.go | 3 + middleware/jwt/verifier.go | 15 +- sessions/sessiondb/redis/database.go | 2 +- 8 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 _examples/auth/jwt/blocklist/main.go create mode 100644 middleware/jwt/blocklist/redis/blocklist.go diff --git a/_examples/auth/jwt/blocklist/main.go b/_examples/auth/jwt/blocklist/main.go new file mode 100644 index 0000000000..6549f65864 --- /dev/null +++ b/_examples/auth/jwt/blocklist/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "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") +} + +// generateID optionally to set the value for `jwt.ID` on Sign, +// which sets the standard claims value "jti". +// If you use a blocklist with the default Blocklist.GetKey you have to set it. +var generateID = func(*context.Context) string { + id, _ := uuid.NewRandom() + return id.String() +} + +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/go.mod b/go.mod index 86a975a635..20a4c918d5 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/json-iterator/go v1.1.10 github.com/kataras/blocks v0.0.4 github.com/kataras/golog v0.1.5 - github.com/kataras/jwt v0.0.4 + github.com/kataras/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 diff --git a/middleware/jwt/alises.go b/middleware/jwt/alises.go index df27cb5db9..81b4f94af1 100644 --- a/middleware/jwt/alises.go +++ b/middleware/jwt/alises.go @@ -1,7 +1,59 @@ package jwt -import ( - "github.com/kataras/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. @@ -31,23 +83,6 @@ type ( TokenPair = jwt.TokenPair ) -// 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 -) - // Encryption algorithms. var ( GCM = jwt.GCM @@ -73,6 +108,13 @@ var ( // 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. @@ -82,24 +124,3 @@ var ( Sign = jwt.Sign SignEncrypted = jwt.SignEncrypted ) - -// Signature algorithm helpers. -var ( - MustLoadHMAC = jwt.MustLoadHMAC - LoadHMAC = jwt.LoadHMAC - MustLoadRSA = jwt.MustLoadRSA - LoadPrivateKeyRSA = jwt.LoadPrivateKeyRSA - LoadPublicKeyRSA = jwt.LoadPublicKeyRSA - ParsePrivateKeyRSA = jwt.ParsePrivateKeyRSA - ParsePublicKeyRSA = jwt.ParsePublicKeyRSA - MustLoadECDSA = jwt.MustLoadECDSA - LoadPrivateKeyECDSA = jwt.LoadPrivateKeyECDSA - LoadPublicKeyECDSA = jwt.LoadPublicKeyECDSA - ParsePrivateKeyECDSA = jwt.ParsePrivateKeyECDSA - ParsePublicKeyECDSA = jwt.ParsePublicKeyECDSA - MustLoadEdDSA = jwt.MustLoadEdDSA - LoadPrivateKeyEdDSA = jwt.LoadPrivateKeyEdDSA - LoadPublicKeyEdDSA = jwt.LoadPublicKeyEdDSA - ParsePrivateKeyEdDSA = jwt.ParsePrivateKeyEdDSA - ParsePublicKeyEdDSA = jwt.ParsePublicKeyEdDSA -) diff --git a/middleware/jwt/blocklist.go b/middleware/jwt/blocklist.go index 1c827969b2..da1ed6e66c 100644 --- a/middleware/jwt/blocklist.go +++ b/middleware/jwt/blocklist.go @@ -16,11 +16,16 @@ type Blocklist interface { jwt.TokenValidator // InvalidateToken should invalidate a verified JWT token. - InvalidateToken(token []byte, expiry int64) + InvalidateToken(token []byte, c Claims) error // Del should remove a token from the storage. - Del(token []byte) - // Count should return the total amount of tokens stored. - Count() int + Del(key string) error // Has should report whether a specific token exists in the storage. - Has(token []byte) bool + 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 0000000000..831429ef6a --- /dev/null +++ b/middleware/jwt/blocklist/redis/blocklist.go @@ -0,0 +1,188 @@ +package redis + +import ( + "context" + "io" + "sync/atomic" + "time" + + "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 { + Clock func() time.Time // Required. Defaults to time.Now. + // 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{ + Clock: time.Now, + 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/signer.go b/middleware/jwt/signer.go index acd74237e1..a121990a34 100644 --- a/middleware/jwt/signer.go +++ b/middleware/jwt/signer.go @@ -16,6 +16,9 @@ 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 Encrypt func([]byte) ([]byte, error) diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go index 1d354ec92d..6df2788e70 100644 --- a/middleware/jwt/verifier.go +++ b/middleware/jwt/verifier.go @@ -112,7 +112,7 @@ func (v *Verifier) WithDefaultBlocklist() *Verifier { func (v *Verifier) invalidate(ctx *context.Context) { if verifiedToken := GetVerifiedToken(ctx); verifiedToken != nil { - v.Blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims.Expiry) + v.Blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims) ctx.Values().Remove(claimsContextKey) ctx.Values().Remove(verifiedTokenContextKey) ctx.SetUser(nil) @@ -179,6 +179,19 @@ func (v *Verifier) Verify(claimsType func() interface{}, validators ...TokenVali } 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...)...) } diff --git a/sessions/sessiondb/redis/database.go b/sessions/sessiondb/redis/database.go index 6eabee94a0..1773a208a2 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 ) From ed38047385784c4a1fb67a1aede3b8f4796b46a8 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Mon, 2 Nov 2020 18:46:38 +0200 Subject: [PATCH 15/19] add an example for sessions + view data as requested --- _examples/README.md | 1 + _examples/auth/jwt/blocklist/main.go | 9 -- _examples/auth/jwt/tutorial/api.go | 25 ++++++ _examples/auth/jwt/tutorial/go.mod | 10 +++ _examples/auth/jwt/tutorial/main.go | 89 ++++++++++++++++++++ _examples/auth/jwt/tutorial/user.go | 63 ++++++++++++++ _examples/sessions/viewdata/main.go | 42 +++++++++ _examples/sessions/viewdata/views/index.html | 11 +++ middleware/jwt/blocklist/redis/blocklist.go | 3 - 9 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 _examples/auth/jwt/tutorial/api.go create mode 100644 _examples/auth/jwt/tutorial/go.mod create mode 100644 _examples/auth/jwt/tutorial/main.go create mode 100644 _examples/auth/jwt/tutorial/user.go create mode 100644 _examples/sessions/viewdata/main.go create mode 100644 _examples/sessions/viewdata/views/index.html diff --git a/_examples/README.md b/_examples/README.md index da8c458bbd..03706f6d12 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -221,6 +221,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/jwt/blocklist/main.go b/_examples/auth/jwt/blocklist/main.go index 6549f65864..8f3362c8fc 100644 --- a/_examples/auth/jwt/blocklist/main.go +++ b/_examples/auth/jwt/blocklist/main.go @@ -1,7 +1,6 @@ package main import ( - "context" "time" "github.com/kataras/iris/v12" @@ -62,14 +61,6 @@ func main() { app.Listen(":8080") } -// generateID optionally to set the value for `jwt.ID` on Sign, -// which sets the standard claims value "jti". -// If you use a blocklist with the default Blocklist.GetKey you have to set it. -var generateID = func(*context.Context) string { - id, _ := uuid.NewRandom() - return id.String() -} - func authenticate(ctx iris.Context) { claims := userClaims{ Username: "kataras", diff --git a/_examples/auth/jwt/tutorial/api.go b/_examples/auth/jwt/tutorial/api.go new file mode 100644 index 0000000000..688551e932 --- /dev/null +++ b/_examples/auth/jwt/tutorial/api.go @@ -0,0 +1,25 @@ +package main + +import "github.com/kataras/iris/v12" + +func loginView(ctx iris.Context) { + +} + +func login(ctx iris.Context) { + +} + +func logout(ctx iris.Context) { + ctx.Logout() + + ctx.Redirect("/", iris.StatusTemporaryRedirect) +} + +func createTodo(ctx iris.Context) { + +} + +func getTodo(ctx iris.Context) { + +} diff --git a/_examples/auth/jwt/tutorial/go.mod b/_examples/auth/jwt/tutorial/go.mod new file mode 100644 index 0000000000..99e567cc4d --- /dev/null +++ b/_examples/auth/jwt/tutorial/go.mod @@ -0,0 +1,10 @@ +module myapp + +go 1.15 + +require ( + github.com/kataras/iris/v12 v12.2.0-alpha.0.20201031040657-23d4c411cadb + github.com/google/uuid v1.1.2 +) + +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 0000000000..2dee4c0018 --- /dev/null +++ b/_examples/auth/jwt/tutorial/main.go @@ -0,0 +1,89 @@ +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) +) + +func main() { + app := iris.New() + + blocklist := redis.NewBlocklist() + verifier.Blocklist = blocklist + verifyMiddleware := verifier.Verify(func() interface{} { + return new(userClaims) + }) + + app.Get("/", loginView) + + api := app.Party("/api") + { + api.Post("/login", login) + api.Post("/logout", verifyMiddleware, logout) + + todoAPI := api.Party("/todos", verifyMiddleware) + { + todoAPI.Post("/", createTodo) + todoAPI.Get("/", listTodos) + todoAPI.Get("/{id:uint64}", getTodo) + } + } + + protectedAPI := app.Party("/protected", verifyMiddleware) + protectedAPI.Get("/", protected) + protectedAPI.Get("/logout", logout) + + // GET http://localhost:8080 + // POST http://localhost:8080/api/login + // POST http://localhost:8080/api/logout + // POST http://localhost:8080/api/todos + // GET http://localhost:8080/api/todos + // GET http://localhost:8080/api/todos/{id} + 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) +} diff --git a/_examples/auth/jwt/tutorial/user.go b/_examples/auth/jwt/tutorial/user.go new file mode 100644 index 0000000000..3f9bb9b11a --- /dev/null +++ b/_examples/auth/jwt/tutorial/user.go @@ -0,0 +1,63 @@ +package main + +import "golang.org/x/crypto/bcrypt" + +func init() { + generateSampleUsers() +} + +// User represents our User model. +type User struct { + ID uint64 `json:"id"` + Username string `json:"username"` + HashedPassword []byte `json:"-"` +} + +// Users represents a user database. +// For the sake of the tutorial we use a simple slice of users. +var Users []User + +func generateSampleUsers() { + Users = []User{ + {ID: 1, Username: "vasiliki", HashedPassword: mustGeneratePassword("vasiliki_pass")}, // my grandmother. + {ID: 2, Username: "kataras", HashedPassword: mustGeneratePassword("kataras_pass")}, // me. + {ID: 3, Username: "george", HashedPassword: mustGeneratePassword("george_pass")}, // my young brother. + {ID: 4, Username: "kwstas", HashedPassword: mustGeneratePassword("kwstas_pass")}, // my youngest brother. + } +} + +func fetchUser(username, password string) (User, bool) { + for _, u := range Users { // our example uses a static slice. + if u.Username == username { + // we compare the user input and the stored hashed password. + ok := ValidatePassword(password, u.HashedPassword) + if ok { + return u, true + } + } + } + + return User{}, false +} + +// 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/sessions/viewdata/main.go b/_examples/sessions/viewdata/main.go new file mode 100644 index 0000000000..44aae9c1eb --- /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 0000000000..f93e5a3825 --- /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/middleware/jwt/blocklist/redis/blocklist.go b/middleware/jwt/blocklist/redis/blocklist.go index 831429ef6a..74b63cd842 100644 --- a/middleware/jwt/blocklist/redis/blocklist.go +++ b/middleware/jwt/blocklist/redis/blocklist.go @@ -4,7 +4,6 @@ import ( "context" "io" "sync/atomic" - "time" "github.com/kataras/iris/v12/core/host" "github.com/kataras/iris/v12/middleware/jwt" @@ -30,7 +29,6 @@ type Client interface { // Blocklist is a jwt.Blocklist backed by Redis. type Blocklist struct { - Clock func() time.Time // Required. Defaults to time.Now. // 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"). @@ -67,7 +65,6 @@ var _ jwt.Blocklist = (*Blocklist)(nil) // verifier.Blocklist = blocklist func NewBlocklist() *Blocklist { return &Blocklist{ - Clock: time.Now, GetKey: defaultGetKey, Prefix: "", ClientOptions: Options{ From 579c3878f010f258ee5cae12bba5652a24b671e0 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Wed, 4 Nov 2020 21:12:13 +0200 Subject: [PATCH 16/19] add a jwt tutorial + go client --- _examples/README.md | 4 +- _examples/auth/jwt/tutorial/api.go | 25 --- _examples/auth/jwt/tutorial/api/auth.go | 142 ++++++++++++++++++ _examples/auth/jwt/tutorial/api/todo.go | 119 +++++++++++++++ .../auth/jwt/tutorial/domain/model/role.go | 9 ++ .../auth/jwt/tutorial/domain/model/todo.go | 10 ++ .../auth/jwt/tutorial/domain/model/user.go | 9 ++ .../jwt/tutorial/domain/repository/samples.go | 45 ++++++ .../domain/repository/todo_repository.go | 94 ++++++++++++ .../domain/repository/user_repository.go | 82 ++++++++++ .../auth/jwt/tutorial/go-client/README.md | 12 ++ .../auth/jwt/tutorial/go-client/client.go | 109 ++++++++++++++ _examples/auth/jwt/tutorial/go-client/main.go | 69 +++++++++ _examples/auth/jwt/tutorial/go.mod | 3 +- _examples/auth/jwt/tutorial/main.go | 91 +++-------- _examples/auth/jwt/tutorial/user.go | 63 -------- _examples/auth/jwt/tutorial/util/app.go | 7 + _examples/auth/jwt/tutorial/util/clock.go | 7 + _examples/auth/jwt/tutorial/util/password.go | 25 +++ _examples/auth/jwt/tutorial/util/uuid.go | 23 +++ middleware/jwt/signer.go | 26 +++- middleware/jwt/verifier.go | 7 + 22 files changed, 818 insertions(+), 163 deletions(-) delete mode 100644 _examples/auth/jwt/tutorial/api.go create mode 100644 _examples/auth/jwt/tutorial/api/auth.go create mode 100644 _examples/auth/jwt/tutorial/api/todo.go create mode 100644 _examples/auth/jwt/tutorial/domain/model/role.go create mode 100644 _examples/auth/jwt/tutorial/domain/model/todo.go create mode 100644 _examples/auth/jwt/tutorial/domain/model/user.go create mode 100644 _examples/auth/jwt/tutorial/domain/repository/samples.go create mode 100644 _examples/auth/jwt/tutorial/domain/repository/todo_repository.go create mode 100644 _examples/auth/jwt/tutorial/domain/repository/user_repository.go create mode 100644 _examples/auth/jwt/tutorial/go-client/README.md create mode 100644 _examples/auth/jwt/tutorial/go-client/client.go create mode 100644 _examples/auth/jwt/tutorial/go-client/main.go delete mode 100644 _examples/auth/jwt/tutorial/user.go create mode 100644 _examples/auth/jwt/tutorial/util/app.go create mode 100644 _examples/auth/jwt/tutorial/util/clock.go create mode 100644 _examples/auth/jwt/tutorial/util/password.go create mode 100644 _examples/auth/jwt/tutorial/util/uuid.go diff --git a/_examples/README.md b/_examples/README.md index 03706f6d12..3eed3807b0 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -199,9 +199,11 @@ * [Basic Authentication](auth/basicauth/main.go) * [CORS](auth/cors) * JSON Web Tokens - * [Overview](auth/jwt/overview/main.go) * [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) diff --git a/_examples/auth/jwt/tutorial/api.go b/_examples/auth/jwt/tutorial/api.go deleted file mode 100644 index 688551e932..0000000000 --- a/_examples/auth/jwt/tutorial/api.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import "github.com/kataras/iris/v12" - -func loginView(ctx iris.Context) { - -} - -func login(ctx iris.Context) { - -} - -func logout(ctx iris.Context) { - ctx.Logout() - - ctx.Redirect("/", iris.StatusTemporaryRedirect) -} - -func createTodo(ctx iris.Context) { - -} - -func getTodo(ctx iris.Context) { - -} diff --git a/_examples/auth/jwt/tutorial/api/auth.go b/_examples/auth/jwt/tutorial/api/auth.go new file mode 100644 index 0000000000..b4c690772e --- /dev/null +++ b/_examples/auth/jwt/tutorial/api/auth.go @@ -0,0 +1,142 @@ +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. +// It's not used as we don't attach a blocklist, the user can be logged out from our client +// and we don't use access token so we don't actually need this in this example. +func SignOut(ctx iris.Context) { + ctx.Logout() +} + +// 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 0000000000..317494e854 --- /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 0000000000..00fd1c2567 --- /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 0000000000..d0a2ea2737 --- /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 0000000000..9fa8bcdfd1 --- /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 0000000000..b7d267ba08 --- /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 0000000000..a3c8429f37 --- /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 0000000000..b6e0bd7644 --- /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 0000000000..f7a96a9cfd --- /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 0000000000..e96b769f22 --- /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 0000000000..d22688e13a --- /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 index 99e567cc4d..3bcd36bd3b 100644 --- a/_examples/auth/jwt/tutorial/go.mod +++ b/_examples/auth/jwt/tutorial/go.mod @@ -3,8 +3,9 @@ module myapp go 1.15 require ( - github.com/kataras/iris/v12 v12.2.0-alpha.0.20201031040657-23d4c411cadb 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 index 2dee4c0018..8be4308118 100644 --- a/_examples/auth/jwt/tutorial/main.go +++ b/_examples/auth/jwt/tutorial/main.go @@ -1,89 +1,40 @@ package main import ( - "time" + "myapp/api" + "myapp/domain/repository" "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) + userRepository = repository.NewMemoryUserRepository() + todoRepository = repository.NewMemoryTodoRepository() ) func main() { - app := iris.New() - - blocklist := redis.NewBlocklist() - verifier.Blocklist = blocklist - verifyMiddleware := verifier.Verify(func() interface{} { - return new(userClaims) - }) - - app.Get("/", loginView) - - api := app.Party("/api") - { - api.Post("/login", login) - api.Post("/logout", verifyMiddleware, logout) - - todoAPI := api.Party("/todos", verifyMiddleware) - { - todoAPI.Post("/", createTodo) - todoAPI.Get("/", listTodos) - todoAPI.Get("/{id:uint64}", getTodo) - } + if err := repository.GenerateSamples(userRepository, todoRepository); err != nil { + panic(err) } - protectedAPI := app.Party("/protected", verifyMiddleware) - protectedAPI.Get("/", protected) - protectedAPI.Get("/logout", logout) - - // GET http://localhost:8080 - // POST http://localhost:8080/api/login - // POST http://localhost:8080/api/logout - // POST http://localhost:8080/api/todos - // GET http://localhost:8080/api/todos - // GET http://localhost:8080/api/todos/{id} - 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)) + app := iris.New() - if err != nil { - ctx.StopWithError(iris.StatusInternalServerError, err) - return - } + app.Post("/signin", api.SignIn(userRepository)) - ctx.Write(token) -} + verify := api.Verify() -func protected(ctx iris.Context) { - claims := jwt.Get(ctx).(*userClaims) + todosAPI := app.Party("/todos", verify) + todosAPI.Post("/", api.CreateTodo(todoRepository)) + todosAPI.Get("/", api.ListTodos(todoRepository)) + todosAPI.Get("/{id}", api.GetTodo(todoRepository)) - // To the standard claims, e.g. the generated ID: - // jwt.GetVerifiedToken(ctx).StandardClaims.ID + adminAPI := app.Party("/admin", verify, api.AllowAdmin) + adminAPI.Get("/todos", api.ListAllTodos(todoRepository)) - ctx.WriteString(claims.Username) + // 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/user.go b/_examples/auth/jwt/tutorial/user.go deleted file mode 100644 index 3f9bb9b11a..0000000000 --- a/_examples/auth/jwt/tutorial/user.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import "golang.org/x/crypto/bcrypt" - -func init() { - generateSampleUsers() -} - -// User represents our User model. -type User struct { - ID uint64 `json:"id"` - Username string `json:"username"` - HashedPassword []byte `json:"-"` -} - -// Users represents a user database. -// For the sake of the tutorial we use a simple slice of users. -var Users []User - -func generateSampleUsers() { - Users = []User{ - {ID: 1, Username: "vasiliki", HashedPassword: mustGeneratePassword("vasiliki_pass")}, // my grandmother. - {ID: 2, Username: "kataras", HashedPassword: mustGeneratePassword("kataras_pass")}, // me. - {ID: 3, Username: "george", HashedPassword: mustGeneratePassword("george_pass")}, // my young brother. - {ID: 4, Username: "kwstas", HashedPassword: mustGeneratePassword("kwstas_pass")}, // my youngest brother. - } -} - -func fetchUser(username, password string) (User, bool) { - for _, u := range Users { // our example uses a static slice. - if u.Username == username { - // we compare the user input and the stored hashed password. - ok := ValidatePassword(password, u.HashedPassword) - if ok { - return u, true - } - } - } - - return User{}, false -} - -// 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/app.go b/_examples/auth/jwt/tutorial/util/app.go new file mode 100644 index 0000000000..7a991f01cd --- /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 0000000000..34e35eb225 --- /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 0000000000..c18d0a9160 --- /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 0000000000..0269d62d79 --- /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/middleware/jwt/signer.go b/middleware/jwt/signer.go index a121990a34..583f8c00fe 100644 --- a/middleware/jwt/signer.go +++ b/middleware/jwt/signer.go @@ -19,7 +19,8 @@ type Signer struct { // MaxAge to set "exp" and "iat". // Recommended value for access tokens: 15 minutes. // Defaults to 0, no limit. - MaxAge time.Duration + MaxAge time.Duration + Options []SignOption Encrypt func([]byte) ([]byte, error) } @@ -33,11 +34,24 @@ type Signer struct { // signer := NewSigner(HS256, secret, 15*time.Minute) // token, err := signer.Sign(userClaims{Username: "kataras"}) func NewSigner(signatureAlg Alg, signatureKey interface{}, maxAge time.Duration) *Signer { - return &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. @@ -53,7 +67,13 @@ func (s *Signer) WithEncryption(key, additionalData []byte) *Signer { // 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) { - return SignEncrypted(s.Alg, s.Key, s.Encrypt, claims, append([]SignOption{MaxAge(s.MaxAge)}, opts...)...) + 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 diff --git a/middleware/jwt/verifier.go b/middleware/jwt/verifier.go index 6df2788e70..b5fd75c005 100644 --- a/middleware/jwt/verifier.go +++ b/middleware/jwt/verifier.go @@ -80,6 +80,13 @@ type Verifier struct { // 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, From a9e808345b4d0c71c38387f3930aae34087cd363 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Wed, 4 Nov 2020 21:31:15 +0200 Subject: [PATCH 17/19] minor --- _examples/auth/jwt/tutorial/README.md | 50 +++++++++++++++++++++++++ _examples/auth/jwt/tutorial/api/auth.go | 4 +- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 _examples/auth/jwt/tutorial/README.md diff --git a/_examples/auth/jwt/tutorial/README.md b/_examples/auth/jwt/tutorial/README.md new file mode 100644 index 0000000000..a577622332 --- /dev/null +++ b/_examples/auth/jwt/tutorial/README.md @@ -0,0 +1,50 @@ +# Iris JWT Tutorial + +```sh +$ go run main.go +``` + +```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 +``` + +```sh +$ curl --location --request GET 'http://localhost:8080/todos' \ +--header 'Authorization: Bearer $token' + +> $todos +``` + +```sh +$ curl --location --request GET 'http://localhost:8080/todos/$id' \ +--header 'Authorization: Bearer $token' + +> $todo +``` + +```sh +$ curl --location --request GET 'http://localhost:8080/admin/todos' \ +--header 'Authorization: Bearer $token' + +> $todos +``` + +```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 +``` + +TODO: write the article on https://medium.com/@kataras, https://dev.to/kataras and linkedin first. diff --git a/_examples/auth/jwt/tutorial/api/auth.go b/_examples/auth/jwt/tutorial/api/auth.go index b4c690772e..2e540e0991 100644 --- a/_examples/auth/jwt/tutorial/api/auth.go +++ b/_examples/auth/jwt/tutorial/api/auth.go @@ -113,10 +113,8 @@ func SignIn(repo repository.UserRepository) iris.Handler { } // SignOut invalidates a user from server-side using the jwt Blocklist. -// It's not used as we don't attach a blocklist, the user can be logged out from our client -// and we don't use access token so we don't actually need this in this example. func SignOut(ctx iris.Context) { - ctx.Logout() + 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. From d562f09531e57b62ecc1be9d197085089920d25d Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Thu, 5 Nov 2020 10:47:56 +0200 Subject: [PATCH 18/19] OK, I think we are done with the new JWT package --- _examples/auth/jwt/tutorial/README.md | 16 +++++- .../dependency-injection/jwt/contrib/go.mod | 4 +- _examples/dependency-injection/jwt/main.go | 53 +++++++++++++------ 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/_examples/auth/jwt/tutorial/README.md b/_examples/auth/jwt/tutorial/README.md index a577622332..5a52c56b96 100644 --- a/_examples/auth/jwt/tutorial/README.md +++ b/_examples/auth/jwt/tutorial/README.md @@ -1,9 +1,15 @@ # 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' \ @@ -13,6 +19,8 @@ $ curl --location --request POST 'http://localhost:8080/signin' \ > $token ``` +## Get all TODOs for this User + ```sh $ curl --location --request GET 'http://localhost:8080/todos' \ --header 'Authorization: Bearer $token' @@ -20,6 +28,8 @@ $ curl --location --request GET 'http://localhost:8080/todos' \ > $todos ``` +## Get a specific User's TODO + ```sh $ curl --location --request GET 'http://localhost:8080/todos/$id' \ --header 'Authorization: Bearer $token' @@ -27,6 +37,8 @@ $ curl --location --request GET 'http://localhost:8080/todos/$id' \ > $todo ``` +## Get all TODOs for all Users (admin role) + ```sh $ curl --location --request GET 'http://localhost:8080/admin/todos' \ --header 'Authorization: Bearer $token' @@ -34,6 +46,8 @@ $ curl --location --request GET 'http://localhost:8080/admin/todos' \ > $todos ``` +## Create a new TODO + ```sh $ curl --location --request POST 'http://localhost:8080/todos' \ --header 'Authorization: Bearer $token' \ @@ -46,5 +60,3 @@ $ curl --location --request POST 'http://localhost:8080/todos' \ > Status Created > $todo ``` - -TODO: write the article on https://medium.com/@kataras, https://dev.to/kataras and linkedin first. diff --git a/_examples/dependency-injection/jwt/contrib/go.mod b/_examples/dependency-injection/jwt/contrib/go.mod index 990ef4e0b8..97b9162e93 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 c705fac9a8..54c105cbc1 100644 --- a/_examples/dependency-injection/jwt/main.go +++ b/_examples/dependency-injection/jwt/main.go @@ -11,40 +11,59 @@ func main() { app := iris.New() app.ConfigureContainer(register) + // http://localhost:8080/authenticate + // http://localhost:8080/restricted app.Listen(":8080") } -func register(api *iris.APIContainer) { - j := jwt.HMAC(15*time.Minute, "secret", "secretforencrypt") +var ( + secret = []byte("secret") + signer = jwt.NewSigner(jwt.HS256, secret, 15*time.Minute) + verify = jwt.NewVerifier(jwt.HS256, secret, jwt.Expected{Issuer: "myapp"}).Verify(func() interface{} { + return new(userClaims) + }) +) - api.RegisterDependency(func(ctx iris.Context) (claims userClaims) { - if err := j.VerifyToken(ctx, &claims); err != nil { - ctx.StopWithError(iris.StatusUnauthorized, err) - return +func register(api *iris.APIContainer) { + // To register the middleware in the whole api container: + // api.Use(verify) + // Otherwise, protect routes when userClaims is expected on the functions input + // by calling the middleware manually, see below. + api.RegisterDependency(func(ctx iris.Context) (claims *userClaims) { + 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) } - return + return nil }) - 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", } + standardClaims := jwt.Claims{ + Issuer: "myapp", + } + + token, err := signer.Sign(claims, standardClaims) + if err != nil { + ctx.StopWithError(iris.StatusInternalServerError, err) + return + } + + ctx.Write(token) } -func restrictedPage(claims userClaims) string { +func restrictedPage(claims *userClaims) string { // userClaims.Username: kataras return "userClaims.Username: " + claims.Username } From f049c51336a3d0101df32277b26adae4106699d3 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Fri, 6 Nov 2020 11:36:57 +0200 Subject: [PATCH 19/19] last touch --- _examples/dependency-injection/jwt/main.go | 41 +++++++++++----------- middleware/jwt/{alises.go => aliases.go} | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) rename middleware/jwt/{alises.go => aliases.go} (99%) diff --git a/_examples/dependency-injection/jwt/main.go b/_examples/dependency-injection/jwt/main.go index 54c105cbc1..ff328eb9a2 100644 --- a/_examples/dependency-injection/jwt/main.go +++ b/_examples/dependency-injection/jwt/main.go @@ -12,30 +12,34 @@ func main() { app.ConfigureContainer(register) // http://localhost:8080/authenticate - // http://localhost:8080/restricted + // http://localhost:8080/restricted (Header: Authorization = Bearer $token) app.Listen(":8080") } -var ( - secret = []byte("secret") - signer = jwt.NewSigner(jwt.HS256, secret, 15*time.Minute) - verify = jwt.NewVerifier(jwt.HS256, secret, jwt.Expected{Issuer: "myapp"}).Verify(func() interface{} { - return new(userClaims) - }) -) +var secret = []byte("secret") func register(api *iris.APIContainer) { - // To register the middleware in the whole api container: - // api.Use(verify) - // Otherwise, protect routes when userClaims is expected on the functions input - // by calling the middleware manually, see below. - api.RegisterDependency(func(ctx iris.Context) (claims *userClaims) { - if ctx.Proceed(verify) { // the "verify" middleware will stop the execution if it's failed to verify the request. + api.RegisterDependency(func(ctx iris.Context) (claims userClaims) { + /* 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 } - return nil + verifiedToken.Claims(&claims) + return }) api.Get("/authenticate", writeToken) @@ -50,11 +54,8 @@ func writeToken(ctx iris.Context) { claims := userClaims{ Username: "kataras", } - standardClaims := jwt.Claims{ - Issuer: "myapp", - } - token, err := signer.Sign(claims, standardClaims) + token, err := jwt.Sign(jwt.HS256, secret, claims, jwt.MaxAge(1*time.Minute)) if err != nil { ctx.StopWithError(iris.StatusInternalServerError, err) return @@ -63,7 +64,7 @@ func writeToken(ctx iris.Context) { ctx.Write(token) } -func restrictedPage(claims *userClaims) string { +func restrictedPage(claims userClaims) string { // userClaims.Username: kataras return "userClaims.Username: " + claims.Username } diff --git a/middleware/jwt/alises.go b/middleware/jwt/aliases.go similarity index 99% rename from middleware/jwt/alises.go rename to middleware/jwt/aliases.go index 81b4f94af1..475d4692f0 100644 --- a/middleware/jwt/alises.go +++ b/middleware/jwt/aliases.go @@ -119,7 +119,7 @@ var ( // Shortcuts for Signing and Verifying. var ( - VerifyToken = jwt.Verify + Verify = jwt.Verify VerifyEncryptedToken = jwt.VerifyEncrypted Sign = jwt.Sign SignEncrypted = jwt.SignEncrypted