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

Groups Claim #301

Merged
merged 1 commit into from
Jan 7, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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