From 93b49b6d173b424f69d738fb43593e928429f7e8 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 3 Oct 2024 19:48:43 +0100 Subject: [PATCH] Add support for roles and groups. --- .traefik.yml | 2 ++ README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 82 +++++++++++++++++++++++++++++++++++++++++++++-- settings.go | 29 +++++++++-------- 4 files changed, 187 insertions(+), 16 deletions(-) diff --git a/.traefik.yml b/.traefik.yml index e57cbce..478bdb7 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -19,6 +19,8 @@ testData: - profile allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no - raczylo.com + allowedRolesAndGroups: + - guest-endpoints sessionEncryptionKey: potato-secret forceHTTPS: false logLevel: debug # debug, info, warn, error diff --git a/README.md b/README.md index 40d8ac6..2c63b31 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,96 @@ Middleware currently supports following scenarios: * Using excluded URLs which do **NOT** require the OIDC authentication * Rate limiting requests to prevent the bruteforce attacks +#### How to configure... + +##### Excluded URLs with open access + +``` +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-with-open-urls + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: xxx + clientID: yyy + clientSecret: zzz + sessionEncryptionKey: vvv + callbackURL: /cool-oidc/callback + logoutURL: /cool-oidc/logout + scopes: + - openid + - email + - profile + excludedURLs: # Determines the list of URLs which are NOT a subject to authentication + - /login # covers /login, /login/me, /login/reminder etc. + - /my-public-data +``` + + +##### Allowed email domains + +Assuming that your OIDC provider allows anyone to log in, you may want to limit the access to people using emains in specific domain. + +``` +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-only-my-users + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: xxx + clientID: yyy + clientSecret: zzz + sessionEncryptionKey: vvv + callbackURL: /new-oidc/callback + logoutURL: /new-oidc/logout + scopes: + - openid + - email + - profile + allowedUserDomains: + - raczylo.com +``` + + +##### Allowed groups and roles + +In case of multiple roles / groups and access separation for various endpoints you will need to create multiple traefik middlewares. +Following example allows access for users who have additional role `guest-endpoints` assigned. + +``` +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-guest-endpoints + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: xxx + clientID: yyy + clientSecret: zzz + sessionEncryptionKey: vvv + callbackURL: /my-oidc/callback + logoutURL: /my-oidc/logout + scopes: + - openid + - email + - profile + - roles # This line queries the OIDC provider for roles + forceHTTPS: true + allowedRolesAndGroups: + - guest-endpoints # This line specifies the roles or groups allowed to access content + allowedUserDomains: + - raczylo.com +``` + + #### Docker compose example `docker-compose.yaml` diff --git a/main.go b/main.go index 376a257..1672aa1 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ type TraefikOidc struct { jwtVerifier JWTVerifier excludedURLs map[string]struct{} allowedUserDomains map[string]struct{} + allowedRolesAndGroups map[string]struct{} initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *sessions.Session, redirectURL string) exchangeCodeForTokenFunc func(code string) (map[string]interface{}, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error) @@ -246,6 +247,13 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h } return m }(), + allowedRolesAndGroups: func() map[string]struct{} { + m := make(map[string]struct{}) + for _, roleOrGroup := range config.AllowedRolesAndGroups { + m[roleOrGroup] = struct{}{} + } + return m + }(), } t.initiateAuthenticationFunc = t.defaultInitiateAuthentication @@ -405,6 +413,34 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } + groups, roles, err := t.extractGroupsAndRoles(idToken) + if err != nil { + t.logger.Errorf("Failed to extract groups and roles: %v", err) + } else { + // Set headers for groups and roles + if len(groups) > 0 { + req.Header.Set("X-User-Groups", strings.Join(groups, ",")) + } + if len(roles) > 0 { + req.Header.Set("X-User-Roles", strings.Join(roles, ",")) + } + } + + if len(t.allowedRolesAndGroups) > 0 { + allowed := false + for _, roleOrGroup := range append(groups, roles...) { + if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok { + allowed = true + break + } + } + if !allowed { + t.logger.Infof("User with email %s does not have any allowed roles or groups", email) + http.Error(rw, fmt.Sprintf("Access denied: You do not have any allowed roles or groups. To log out, visit: %s", t.logoutURLPath), http.StatusForbidden) + return + } + } + req.Header.Set("X-Forwarded-User", email) t.next.ServeHTTP(rw, req) @@ -525,8 +561,6 @@ func (t *TraefikOidc) verifyToken(token string) error { return t.tokenVerifier.VerifyToken(token) } -var authURLBuilder strings.Builder - func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce string) string { params := url.Values{} params.Set("client_id", t.clientID) @@ -639,3 +673,47 @@ func (t *TraefikOidc) isAllowedDomain(email string) bool { _, ok := t.allowedUserDomains[domain] return ok } + +func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) { + claims, err := t.extractClaimsFunc(idToken) + if err != nil { + return nil, nil, fmt.Errorf("failed to extract claims: %w", err) + } + + var groups []string + var roles []string + + // Check for groups claim + if groupsClaim, ok := claims["groups"]; ok { + if groupsSlice, ok := groupsClaim.([]interface{}); ok { + for _, group := range groupsSlice { + if groupStr, ok := group.(string); ok { + t.logger.Debugf("Found group: %s", groupStr) + groups = append(groups, groupStr) + } + } + } + } + + if len(groups) == 0 { + t.logger.Debug("No groups found in groups claim, checking roles claim") + } + + // Check for roles claim + if rolesClaim, ok := claims["roles"]; ok { + if rolesSlice, ok := rolesClaim.([]interface{}); ok { + for _, role := range rolesSlice { + if roleStr, ok := role.(string); ok { + t.logger.Debug("Found role: %s", roleStr) + roles = append(roles, roleStr) + } + } + } + } + + if len(roles) == 0 { + t.logger.Debug("No roles found in roles claim") + } + + return groups, roles, nil +} diff --git a/settings.go b/settings.go index 2213724..93ac1f8 100644 --- a/settings.go +++ b/settings.go @@ -15,20 +15,21 @@ const ( ) type Config struct { - ProviderURL string `json:"providerURL"` - RevocationURL string `json:"revocationURL"` - CallbackURL string `json:"callbackURL"` - LogoutURL string `json:"logoutURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - Scopes []string `json:"scopes"` - LogLevel string `json:"logLevel"` - SessionEncryptionKey string `json:"sessionEncryptionKey"` - ForceHTTPS bool `json:"forceHTTPS"` - RateLimit int `json:"rateLimit"` - ExcludedURLs []string `json:"excludedURLs"` - AllowedUserDomains []string `json:"allowedUserDomains"` - HTTPClient *http.Client + ProviderURL string `json:"providerURL"` + RevocationURL string `json:"revocationURL"` + CallbackURL string `json:"callbackURL"` + LogoutURL string `json:"logoutURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + LogLevel string `json:"logLevel"` + SessionEncryptionKey string `json:"sessionEncryptionKey"` + ForceHTTPS bool `json:"forceHTTPS"` + RateLimit int `json:"rateLimit"` + ExcludedURLs []string `json:"excludedURLs"` + AllowedUserDomains []string `json:"allowedUserDomains"` + AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` + HTTPClient *http.Client } var defaultSessionOptions = &sessions.Options{