From e3d25fc0d0b06f3df13d534dba7354c8f4178a41 Mon Sep 17 00:00:00 2001 From: Sebastian Mancke Date: Wed, 23 Nov 2016 18:24:22 +0100 Subject: [PATCH 1/2] Added redirect option for simple login flows. --- README.md | 4 +++- config.go | 7 +++++++ config_test.go | 16 +++++++++++----- jwt.go | 22 +++++++++++++++++----- jwt_test.go | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 900b816..86f51bc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ This middleware implements an authorization layer for [Caddy](https://caddyserve ### Basic Syntax - ``` jwt [path] ``` @@ -27,6 +26,7 @@ You can optionally use claim information to further control access to your route ``` jwt { path [path] + redirect [location] allow [claim] [value] deny [claim] [value] } @@ -46,6 +46,8 @@ jwt { The middleware will deny everyone with `role: member` but will allow the specific user named `someone`. A different user with a `role: admin` or `role: foo` would be allowed because the deny rule will allow anyone that doesn't have role member. +If the optional `redirect` is set, the middleware will send an redirect to the supplied location (HTTP 303) instead of an access denied code, if the access is denied. + ### Ways of passing a token for validation There are three ways to pass the token for validation: (1) in the `Authorization` header, (2) as a cookie, and (3) as a URL query parameter. The middleware will look in those places in the order listed and return `401` if it can't find any token. diff --git a/config.go b/config.go index 81a7558..2f16ccc 100644 --- a/config.go +++ b/config.go @@ -20,6 +20,7 @@ type JWTAuth struct { type Rule struct { Path string AccessRules []AccessRule + Redirect string } type AccessRule struct { @@ -102,6 +103,12 @@ func parse(c *caddy.Controller) ([]Rule, error) { return nil, c.ArgErr() } r.AccessRules = append(r.AccessRules, AccessRule{Authorize: DENY, Claim: args1[0], Value: args1[1]}) + case "redirect": + args1 := c.RemainingArgs() + if len(args1) != 1 { + return nil, c.ArgErr() + } + r.Redirect = args1[0] } } rules = append(rules, r) diff --git a/config_test.go b/config_test.go index 0e6c414..b545ac2 100644 --- a/config_test.go +++ b/config_test.go @@ -34,12 +34,17 @@ var _ = Describe("JWTAuth Config", func() { shouldErr bool expect []Rule }{ - {"jwt /test", false, []Rule{Rule{"/test", nil}}}, - {"jwt {\npath /test\n}", false, []Rule{Rule{"/test", nil}}}, + {"jwt /test", false, []Rule{{Path: "/test"}}}, + {"jwt {\npath /test\n}", false, []Rule{{Path: "/test"}}}, {`jwt { path /test + redirect /login allow user test - }`, false, []Rule{Rule{"/test", []AccessRule{AccessRule{ALLOW, "user", "test"}}}}}, + }`, false, []Rule{{ + Path: "/test", + Redirect: "/login", + AccessRules: []AccessRule{{ALLOW, "user", "test"}}}, + }}, {`jwt /test { allow user test }`, true, nil}, @@ -47,12 +52,12 @@ var _ = Describe("JWTAuth Config", func() { path /test deny role member allow user test - }`, false, []Rule{Rule{"/test", []AccessRule{AccessRule{DENY, "role", "member"}, AccessRule{ALLOW, "user", "test"}}}}}, + }`, false, []Rule{{Path: "/test", AccessRules: []AccessRule{{DENY, "role", "member"}, {ALLOW, "user", "test"}}}}}, {`jwt { deny role member }`, true, nil}, {`jwt /path1 - jwt /path2`, false, []Rule{Rule{"/path1", nil}, Rule{"/path2", nil}}}, + jwt /path2`, false, []Rule{{Path: "/path1"}, {Path: "/path2"}}}, {`jwt { path /path1 path /path2 @@ -69,6 +74,7 @@ var _ = Describe("JWTAuth Config", func() { for idx, rule := range test.expect { actualRule := actual[idx] Expect(rule.Path).To(Equal(actualRule.Path)) + Expect(rule.Redirect).To(Equal(actualRule.Redirect)) Expect(rule.AccessRules).To(Equal(actualRule.AccessRules)) } diff --git a/jwt.go b/jwt.go index d7efaef..6b766bf 100644 --- a/jwt.go +++ b/jwt.go @@ -21,13 +21,13 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) // Path matches, look for unvalidated token uToken, err := ExtractToken(r) if err != nil { - return http.StatusUnauthorized, nil + return handleUnauthorized(w, r, p), nil } // Validate token vToken, err := ValidateToken(uToken) if err != nil { - return http.StatusUnauthorized, nil + return handleUnauthorized(w, r, p), nil } vClaims := vToken.Claims.(jwt.MapClaims) @@ -51,7 +51,7 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) isAuthorized = append(isAuthorized, true) } default: - return http.StatusUnauthorized, fmt.Errorf("unknown rule type") + return handleUnauthorized(w, r, p), fmt.Errorf("unknown rule type") } } // test all flags, if any are true then ok to pass @@ -62,7 +62,7 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) } } if !ok { - return http.StatusUnauthorized, nil + return handleUnauthorized(w, r, p), nil } } @@ -83,7 +83,7 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) case float64: r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatFloat(value.(float64), 'f', -1, 64)) default: - return http.StatusUnauthorized, fmt.Errorf("unknown claim type, unable to convert to string") + return handleUnauthorized(w, r, p), fmt.Errorf("unknown claim type, unable to convert to string") } } @@ -153,3 +153,15 @@ func lookupSecret() ([]byte, error) { } return []byte(secret), nil } + +// handleUnauthorized checks, which action should be performed if access was denied. +// It returns the status code and writes the Location header in case of a redirect. +// Possible caddy variables in the location value will be substituted. +func handleUnauthorized(w http.ResponseWriter, r *http.Request, rule Rule) int { + if rule.Redirect != "" { + replacer := httpserver.NewReplacer(r, nil, "") + http.Redirect(w, r, replacer.Replace(rule.Redirect), http.StatusSeeOther) + return http.StatusSeeOther + } + return http.StatusUnauthorized +} diff --git a/jwt_test.go b/jwt_test.go index ff9f561..bbf2260 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -167,7 +167,40 @@ var _ = Describe("JWTAuth", func() { Expect(vToken).To(BeNil()) }) }) + Describe("Redirect on access deny works", func() { + It("return 303 when a redirect is configured and access denied", func() { + req, err := http.NewRequest("GET", "/testing", nil) + + rec := httptest.NewRecorder() + rw := JWTAuth{ + Rules: []Rule{{Path: "/testing", Redirect: "/login"}}, + } + result, err := rw.ServeHTTP(rec, req) + if err != nil { + Fail(fmt.Sprintf("unexpected error constructing server: %s", err)) + } + + Expect(result).To(Equal(http.StatusSeeOther)) + Expect(rec.Result().StatusCode).To(Equal(http.StatusSeeOther)) + Expect(rec.Result().Header.Get("Location")).To(Equal("/login")) + }) + It("variables in location value are replaced", func() { + req, err := http.NewRequest("GET", "/testing", nil) + + rec := httptest.NewRecorder() + rw := JWTAuth{ + Rules: []Rule{{Path: "/testing", Redirect: "/login?backTo={uri}"}}, + } + result, err := rw.ServeHTTP(rec, req) + if err != nil { + Fail(fmt.Sprintf("unexpected error constructing server: %s", err)) + } + Expect(result).To(Equal(http.StatusSeeOther)) + Expect(rec.Result().StatusCode).To(Equal(http.StatusSeeOther)) + Expect(rec.Result().Header.Get("Location")).To(Equal("/login?backTo=/testing")) + }) + }) Describe("Function correctly as an authorization middleware", func() { rw := JWTAuth{ Next: httpserver.HandlerFunc(passThruHandler), From b97a52ae1966d79ec5272f2ad1ad11313e7082e7 Mon Sep 17 00:00:00 2001 From: Sebastian Mancke Date: Mon, 19 Dec 2016 23:25:44 +0100 Subject: [PATCH 2/2] Allow string arrays as JWT claims --- README.md | 7 +++++-- jwt.go | 60 ++++++++++++++++++++++++++++++++++------------------- jwt_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 86f51bc..17382f3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ jwt [path2] ### Advanced Syntax -You can optionally use claim information to further control access to your routes. In a `jwt` block you can specify rules to allow or deny access based on the value of a claim. +You can optionally use claim information to further control access to your routes. In a `jwt` block you can specify rules to allow or deny access based on the value of a claim. +If the claim is a json array of strings, the allow and deny directives will check, of the array contains the specified string value. ``` jwt { @@ -87,7 +88,8 @@ You can of course add extra claims in the claim section. Once the token is vali { "user": "test", "role": "admin", -"logins": 10 +"logins": 10, +"groups": ["user", "operator"] } ``` @@ -97,6 +99,7 @@ The following headers will be added to the request that is proxied to your appli Token-Claim-User: test Token-Claim-Role: admin Token-Claim-Logins: 10 +Token-Claim-Groups: user,operator ``` Token claims will always be converted to a string. If you expect your claim to be another type, remember to convert it back before you use it. diff --git a/jwt.go b/jwt.go index 6b766bf..bcbbc7c 100644 --- a/jwt.go +++ b/jwt.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "bytes" "github.com/dgrijalva/jwt-go" "github.com/mholt/caddy/caddyhttp/httpserver" ) @@ -35,21 +36,13 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) if len(p.AccessRules) > 0 { var isAuthorized []bool for _, rule := range p.AccessRules { + v := vClaims[rule.Claim] + ruleMatches := contains(v, rule.Value) || v == rule.Value switch rule.Authorize { case ALLOW: - if vClaims[rule.Claim] == rule.Value { - isAuthorized = append(isAuthorized, true) - } - if vClaims[rule.Claim] != rule.Value { - isAuthorized = append(isAuthorized, false) - } + isAuthorized = append(isAuthorized, ruleMatches) case DENY: - if vClaims[rule.Claim] == rule.Value { - isAuthorized = append(isAuthorized, false) - } - if vClaims[rule.Claim] != rule.Value { - isAuthorized = append(isAuthorized, true) - } + isAuthorized = append(isAuthorized, !ruleMatches) default: return handleUnauthorized(w, r, p), fmt.Errorf("unknown rule type") } @@ -68,22 +61,33 @@ func (h JWTAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) // set claims as separate headers for downstream to consume for claim, value := range vClaims { - c := strings.ToUpper(claim) - switch value.(type) { + headerName := "Token-Claim-" + strings.ToUpper(claim) + switch v := value.(type) { case string: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), value.(string)) + r.Header.Set(headerName, v) case int64: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatInt(value.(int64), 10)) + r.Header.Set(headerName, strconv.FormatInt(v, 10)) case bool: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatBool(value.(bool))) + r.Header.Set(headerName, strconv.FormatBool(v)) case int32: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatInt(int64(value.(int32)), 10)) + r.Header.Set(headerName, strconv.FormatInt(int64(v), 10)) case float32: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatFloat(float64(value.(float32)), 'f', -1, 32)) + r.Header.Set(headerName, strconv.FormatFloat(float64(v), 'f', -1, 32)) case float64: - r.Header.Set(strings.Join([]string{"Token-Claim-", c}, ""), strconv.FormatFloat(value.(float64), 'f', -1, 64)) + r.Header.Set(headerName, strconv.FormatFloat(v, 'f', -1, 64)) + case []interface{}: + b := bytes.NewBufferString("") + for i, item := range v { + if i > 0 { + b.WriteString(",") + } + b.WriteString(fmt.Sprintf("%v", item)) + } + r.Header.Set(headerName, b.String()) default: - return handleUnauthorized(w, r, p), fmt.Errorf("unknown claim type, unable to convert to string") + // ignore, because, JWT spec says in https://tools.ietf.org/html/rfc7519#section-4 + // all claims that are not understood + // by implementations MUST be ignored. } } @@ -165,3 +169,17 @@ func handleUnauthorized(w http.ResponseWriter, r *http.Request, rule Rule) int { } return http.StatusUnauthorized } + +// contains checks weather list is a slice ans containts the +// supplied string value. +func contains(list interface{}, value string) bool { + switch l := list.(type) { + case []interface{}: + for _, v := range l { + if v == value { + return true + } + } + } + return false +} diff --git a/jwt_test.go b/jwt_test.go index bbf2260..3b56e88 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -32,7 +32,7 @@ func passThruHandler(w http.ResponseWriter, r *http.Request) (int, error) { return http.StatusOK, nil } -func genToken(secret string, claims map[string]string) string { +func genToken(secret string, claims map[string]interface{}) string { token := jwt.New(jwt.SigningMethodHS256) token.Claims.(jwt.MapClaims)["exp"] = time.Now().Add(time.Hour * 1).Unix() @@ -217,6 +217,7 @@ var _ = Describe("JWTAuth", func() { token.Claims.(jwt.MapClaims)["float32"] = float32(3.14159) token.Claims.(jwt.MapClaims)["float64"] = float64(3.14159) token.Claims.(jwt.MapClaims)["bool"] = true + token.Claims.(jwt.MapClaims)["list"] = []string{"foo", "bar", "bazz"} validToken, err := token.SignedString([]byte("secret")) if err != nil { @@ -283,6 +284,7 @@ var _ = Describe("JWTAuth", func() { "Token-Claim-Float32": "3.14159", "Token-Claim-Float64": "3.14159", "Token-Claim-Int32": "10", + "Token-Claim-List": "foo,bar,bazz", } returnedHeaders := rec.Header() for head, value := range expectedHeaders { @@ -297,9 +299,9 @@ var _ = Describe("JWTAuth", func() { Describe("Function correctly as an authorization middleware for complex access rules", func() { - tokenUser := genToken("secret", map[string]string{"user": "test", "role": "member"}) - tokenNotUser := genToken("secret", map[string]string{"user": "bad"}) - tokenAdmin := genToken("secret", map[string]string{"role": "admin"}) + tokenUser := genToken("secret", map[string]interface{}{"user": "test", "role": "member"}) + tokenNotUser := genToken("secret", map[string]interface{}{"user": "bad"}) + tokenAdmin := genToken("secret", map[string]interface{}{"role": "admin"}) accessRuleAllowUser := AccessRule{Authorize: ALLOW, Claim: "user", Value: "test", @@ -429,6 +431,55 @@ var _ = Describe("JWTAuth", func() { Expect(result).To(Equal(http.StatusOK)) }) }) + Describe("Function correctly as an authorization middleware for list types", func() { + + tokenGroups := genToken("secret", map[string]interface{}{"group": []string{"admin", "user"}}) + tokenGroupsOperator := genToken("secret", map[string]interface{}{"group": []string{"operator"}}) + ruleAllowUser := Rule{Path: "/testing", AccessRules: []AccessRule{ + AccessRule{Authorize: ALLOW, + Claim: "group", + Value: "admin", + }, + AccessRule{Authorize: DENY, + Claim: "group", + Value: "operator", + }, + }} + It("should allow claim values, which are part of a list", func() { + rw := JWTAuth{ + Next: httpserver.HandlerFunc(passThruHandler), + Rules: []Rule{ruleAllowUser}, + } + + req, err := http.NewRequest("GET", "/testing", nil) + req.Header.Set("Authorization", strings.Join([]string{"Bearer", tokenGroups}, " ")) + + rec := httptest.NewRecorder() + result, err := rw.ServeHTTP(rec, req) + if err != nil { + Fail(fmt.Sprintf("unexpected error constructing server: %s", err)) + } + + Expect(result).To(Equal(http.StatusOK)) + }) + It("should deny claim values, which are part of a list", func() { + rw := JWTAuth{ + Next: httpserver.HandlerFunc(passThruHandler), + Rules: []Rule{ruleAllowUser}, + } + + req, err := http.NewRequest("GET", "/testing", nil) + req.Header.Set("Authorization", strings.Join([]string{"Bearer", tokenGroupsOperator}, " ")) + + rec := httptest.NewRecorder() + result, err := rw.ServeHTTP(rec, req) + if err != nil { + Fail(fmt.Sprintf("unexpected error constructing server: %s", err)) + } + + Expect(result).To(Equal(http.StatusUnauthorized)) + }) + }) })