Skip to content
This repository has been archived by the owner on Dec 7, 2020. It is now read-only.

Commit

Permalink
Groups Claims (#301)
Browse files Browse the repository at this point in the history
The current implementation does not take into account the standard `groups` claim when matching on the resource. This PR add the ability to add an additional field `--groups=list,of,groups` to the resource.

Note unlike the roles which are applied with an AND operation a user simply has to exist in one of the groups specified.
  • Loading branch information
gambol99 committed Jan 7, 2018
1 parent b73e8bd commit 32a8e7e
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 60 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@

#### **2.1.1 (Unreleased)**

FEATURES:
* Added the groups parameter to the resource, permitting users to use the `groups` claim in the token [#PR301](https://github.com/gambol99/keycloak-proxy/pull/301)

#### **2.1.0**

FIXES:
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ resources:
roles:
- client:test1
- client:test2
groups:
- admins
- users
- uri: /backend*
roles:
- client:test1
Expand Down Expand Up @@ -357,6 +360,7 @@ On protected resources the upstream endpoint will receive a number of headers ad
id := user.(*userContext)
cx.Request().Header.Set("X-Auth-Email", id.email)
cx.Request().Header.Set("X-Auth-ExpiresIn", id.expiresAt.String())
cx.Request().Header.Set("X-Auth-Groups", strings.Join(id.groups, ","))
cx.Request().Header.Set("X-Auth-Roles", strings.Join(id.roles, ","))
cx.Request().Header.Set("X-Auth-Subject", id.id)
cx.Request().Header.Set("X-Auth-Token", id.token.Encode())
Expand Down Expand Up @@ -433,6 +437,28 @@ match-claims:
email: ^.*@example.com$
```
#### **Groups Claims**
You can match on the group claims within a token via the `groups` parameter available within the resource. Note while roles are implicitly required i.e. `roles=admin,user` the user MUST have roles 'admin' AND 'user', groups are applied with an OR operation, so `groups=users,testers` requires the user MUST be within 'users' OR 'testers'. At present the claim name is hardcoded to `groups` i.e a JWT token would look like the below.
```JSON
{
"iss": "https://sso.example.com",
"sub": "",
"aud": "test",
"exp": 1515269245,
"iat": 1515182845,
"email": "gambol99@gmail.com",
"groups": [
"group_one",
"group_two"
],
"name": "Rohith"
}
```
Note: I'm also considering changing the way groups are implemented, exchanging for how match-claims are done, such as `--match=[]groups=(a|b|c)` but would mean adding matches to URI resource first.
#### **Custom Pages**
By default the proxy will immediately redirect you for authentication and hand back 403 for access denied. Most users will probably want to present the user with a more friendly sign-in and access denied page. You can pass the command line options (or via config file) paths to the files i.e. --signin-page=PATH. The sign-in page will have a 'redirect' variable passed into the scope and holding the oauth redirection url. If you wish pass additional variables into the templates, perhaps title, sitename etc, you can use the --tags key=pair i.e. --tags title="This is my site"; the variable would be accessible from {{ .title }}
Expand Down
31 changes: 18 additions & 13 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ const (
tokenURL = "/token"
debugURL = "/debug/pprof"

claimPreferredName = "preferred_username"
claimAudience = "aud"
claimResourceAccess = "resource_access"
claimPreferredName = "preferred_username"
claimRealmAccess = "realm_access"
claimResourceAccess = "resource_access"
claimResourceRoles = "roles"
claimGroups = "groups"
)

const (
Expand Down Expand Up @@ -99,6 +100,8 @@ type Resource struct {
WhiteListed bool `json:"white-listed" yaml:"white-listed"`
// Roles the roles required to access this url
Roles []string `json:"roles" yaml:"roles"`
// Groups is a list of groups the user is in
Groups []string `json:"groups" yaml:"groups"`
}

// Config is the configuration for the proxy
Expand Down Expand Up @@ -316,28 +319,30 @@ type reverseProxy interface {
ServeHTTP(rw http.ResponseWriter, req *http.Request)
}

// userContext represents a user
// userContext holds the information extracted the token
type userContext struct {
// the id of the user
id string
// the audience for the token
audience string
// whether the context is from a session cookie or authorization header
bearerToken bool
// the claims associated to the token
claims jose.Claims
// the email associated to the user
email string
// the expiration of the access token
expiresAt time.Time
// groups is a collection of groups the user in in
groups []string
// a name of the user
name string
// the preferred name
// preferredName is the name of the user
preferredName string
// the expiration of the access token
expiresAt time.Time
// a set of roles associated
// roles is a collection of roles the users holds
roles []string
// the audience for the token
audience string
// the access token itself
token jose.JWT
// the claims associated to the token
claims jose.Claims
// whether the context is from a session cookie or authorization header
bearerToken bool
}

// tokenResponse
Expand Down
33 changes: 22 additions & 11 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,18 +245,28 @@ func (r *oauthProxy) admissionMiddleware(resource *Resource) func(http.Handler)
}
user := scope.Identity

// step: we need to check the roles
if roles := len(resource.Roles); roles > 0 {
if !hasRoles(resource.Roles, user.roles) {
r.log.Warn("access denied, invalid roles",
zap.String("access", "denied"),
zap.String("email", user.email),
zap.String("resource", resource.URL),
zap.String("required", resource.getRoles()))
// @step: we need to check the roles
if !hasAccess(resource.Roles, user.roles, true) {
r.log.Warn("access denied, invalid roles",
zap.String("access", "denied"),
zap.String("email", user.email),
zap.String("resource", resource.URL),
zap.String("roles", resource.getRoles()))

next.ServeHTTP(w, req.WithContext(r.accessForbidden(w, req)))
return
}

next.ServeHTTP(w, req.WithContext(r.accessForbidden(w, req)))
return
}
// @step: check if we have any groups, the groups are there
if !hasAccess(resource.Groups, user.groups, false) {
r.log.Warn("access denied, invalid roles",
zap.String("access", "denied"),
zap.String("email", user.email),
zap.String("resource", resource.URL),
zap.String("groups", strings.Join(resource.Groups, ",")))

next.ServeHTTP(w, req.WithContext(r.accessForbidden(w, req)))
return
}

// step: if we have any claim matching, lets validate the tokens has the claims
Expand Down Expand Up @@ -326,6 +336,7 @@ func (r *oauthProxy) headersMiddleware(custom []string) func(http.Handler) http.
user := scope.Identity
req.Header.Set("X-Auth-Email", user.email)
req.Header.Set("X-Auth-ExpiresIn", user.expiresAt.String())
req.Header.Set("X-Auth-Groups", strings.Join(user.groups, ","))
req.Header.Set("X-Auth-Roles", strings.Join(user.roles, ","))
req.Header.Set("X-Auth-Subject", user.id)
req.Header.Set("X-Auth-Userid", user.name)
Expand Down
116 changes: 116 additions & 0 deletions middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type fakeRequest struct {
Cookies []*http.Cookie
Expires time.Duration
FormValues map[string]string
Groups []string
HasCookieToken bool
HasLogin bool
HasToken bool
Expand Down Expand Up @@ -177,6 +178,9 @@ func (f *fakeProxy) RunTests(t *testing.T, requests []fakeRequest) {
if len(c.Roles) > 0 {
token.addRealmRoles(c.Roles)
}
if len(c.Groups) > 0 {
token.addGroups(c.Groups)
}
if c.Expires > 0 || c.Expires < 0 {
token.setExpiration(time.Now().Add(c.Expires))
}
Expand Down Expand Up @@ -573,6 +577,118 @@ func TestWhiteListedRequests(t *testing.T) {
newFakeProxy(cfg).RunTests(t, requests)
}

func TestGroupPermissionsMiddleware(t *testing.T) {
cfg := newFakeKeycloakConfig()
cfg.Resources = []*Resource{
{
URL: "/with_role_and_group*",
Methods: allHTTPMethods,
Groups: []string{"admin"},
Roles: []string{"admin"},
},
{
URL: "/with_group*",
Methods: allHTTPMethods,
Groups: []string{"admin"},
},
{
URL: "/with_many_groups*",
Methods: allHTTPMethods,
Groups: []string{"admin", "user", "tester"},
},
{
URL: "/*",
Methods: allHTTPMethods,
Roles: []string{"user"},
},
}
requests := []fakeRequest{
{
URI: "/",
ExpectedCode: http.StatusUnauthorized,
},
{
URI: "/with_role_and_group/test",
HasToken: true,
Roles: []string{"admin"},
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_role_and_group/test",
HasToken: true,
Groups: []string{"admin"},
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_role_and_group/test",
HasToken: true,
Groups: []string{"admin"},
Roles: []string{"admin"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
{
URI: "/with_group/hello",
HasToken: true,
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_groupdd",
HasToken: true,
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_group/hello",
HasToken: true,
Groups: []string{"bad"},
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_group/hello",
HasToken: true,
Groups: []string{"admin"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
{
URI: "/with_group/hello",
HasToken: true,
Groups: []string{"test", "admin"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
{
URI: "/with_many_groups/test",
HasToken: true,
Groups: []string{"bad"},
ExpectedCode: http.StatusForbidden,
},
{
URI: "/with_many_groups/test",
HasToken: true,
Groups: []string{"user"},
Roles: []string{"test"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
{
URI: "/with_many_groups/test",
HasToken: true,
Groups: []string{"tester", "user"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
{
URI: "/with_many_groups/test",
HasToken: true,
Groups: []string{"bad", "user"},
ExpectedProxy: true,
ExpectedCode: http.StatusOK,
},
}
newFakeProxy(cfg).RunTests(t, requests)
}

func TestRolePermissionsMiddleware(t *testing.T) {
cfg := newFakeKeycloakConfig()
cfg.Resources = []*Resource{
Expand Down
2 changes: 2 additions & 0 deletions resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ func (r *Resource) parse(resource string) (*Resource, error) {
}
case "roles":
r.Roles = strings.Split(kp[1], ",")
case "groups":
r.Groups = strings.Split(kp[1], ",")
case "white-listed":
value, err := strconv.ParseBool(kp[1])
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ func TestResourceParseOk(t *testing.T) {
Option: "uri=/*|methods=any",
Resource: &Resource{URL: "/*", Methods: allHTTPMethods},
},
{
Option: "uri=/*|groups=admin,test",
Resource: &Resource{URL: "/*", Methods: allHTTPMethods, Groups: []string{"admin", "test"}},
},
{
Option: "uri=/*|groups=admin",
Resource: &Resource{URL: "/*", Methods: allHTTPMethods, Groups: []string{"admin"}},
},
}
for i, x := range cs {
r, err := newResource().parse(x.Option)
Expand Down
5 changes: 5 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,11 @@ func (t *fakeToken) setExpiration(tm time.Time) {
t.claims.Add("exp", float64(tm.Unix()))
}

// addGroups adds groups to then token
func (t *fakeToken) addGroups(groups []string) {
t.claims.Add("groups", groups)
}

// addRealmRoles adds realms roles to token
func (t *fakeToken) addRealmRoles(roles []string) {
t.claims.Add("realm_access", map[string]interface{}{
Expand Down
Loading

0 comments on commit 32a8e7e

Please sign in to comment.