mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
Add user email filtering logic.
This commit is contained in:
@@ -45,6 +45,10 @@ testData:
|
|||||||
- company.com
|
- company.com
|
||||||
- subsidiary.com
|
- subsidiary.com
|
||||||
|
|
||||||
|
allowedUsers: # Restricts access to specific email addresses regardless of domain
|
||||||
|
- specific-user@company.com
|
||||||
|
- another-user@gmail.com
|
||||||
|
|
||||||
allowedRolesAndGroups: # Restricts access to users with specific roles or groups (if not provided, no role/group restrictions)
|
allowedRolesAndGroups: # Restricts access to users with specific roles or groups (if not provided, no role/group restrictions)
|
||||||
- guest-endpoints
|
- guest-endpoints
|
||||||
- admin
|
- admin
|
||||||
@@ -215,6 +219,21 @@ configuration:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
allowedUsers:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Restricts access to specific email addresses.
|
||||||
|
If provided, only users with these exact email addresses will be allowed access,
|
||||||
|
in addition to any domain-level restrictions set by allowedUserDomains.
|
||||||
|
|
||||||
|
This provides fine-grained control over individual access and can be used
|
||||||
|
together with allowedUserDomains for flexible access control strategies.
|
||||||
|
|
||||||
|
Examples: ["user1@example.com", "admin@company.com"]
|
||||||
|
required: false
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ The middleware supports the following configuration options:
|
|||||||
| `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
|
| `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
|
||||||
| `excludedURLs` | Lists paths that bypass authentication | none | `["/health", "/metrics", "/public"]` |
|
| `excludedURLs` | Lists paths that bypass authentication | none | `["/health", "/metrics", "/public"]` |
|
||||||
| `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
|
| `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
|
||||||
|
| `allowedUsers` | A list of specific email addresses that are allowed access | none | `["user1@example.com", "user2@another.org"]` |
|
||||||
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
|
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
|
||||||
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
|
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
|
||||||
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
||||||
@@ -159,6 +160,67 @@ spec:
|
|||||||
- subsidiary.com
|
- subsidiary.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### With Specific User Access
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-specific-users
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://accounts.google.com
|
||||||
|
clientID: 1234567890.apps.googleusercontent.com
|
||||||
|
clientSecret: your-client-secret
|
||||||
|
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
logoutURL: /oauth2/logout
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
allowedUsers:
|
||||||
|
- user1@example.com
|
||||||
|
- user2@another.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Both Domain and Specific User Access
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-domain-and-users
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://accounts.google.com
|
||||||
|
clientID: 1234567890.apps.googleusercontent.com
|
||||||
|
clientSecret: your-client-secret
|
||||||
|
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
logoutURL: /oauth2/logout
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
allowedUserDomains:
|
||||||
|
- company.com
|
||||||
|
allowedUsers:
|
||||||
|
- special-user@gmail.com
|
||||||
|
- contractor@external.org
|
||||||
|
```
|
||||||
|
|
||||||
|
When configuring access control:
|
||||||
|
- If only `allowedUsers` is set, only the specified email addresses will be granted access
|
||||||
|
- If only `allowedUserDomains` is set, only users with email addresses from those domains will be granted access
|
||||||
|
- If both are set, access is granted if the user's email is in `allowedUsers` OR their email's domain is in `allowedUserDomains`
|
||||||
|
- If neither is set, any authenticated user will be granted access
|
||||||
|
- Email matching is case-insensitive
|
||||||
|
|
||||||
### With Role-Based Access Control
|
### With Role-Based Access Control
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -452,6 +514,9 @@ http:
|
|||||||
- profile
|
- profile
|
||||||
allowedUserDomains:
|
allowedUserDomains:
|
||||||
- company.com
|
- company.com
|
||||||
|
allowedUsers:
|
||||||
|
- special-user@gmail.com
|
||||||
|
- contractor@external.org
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
- admin
|
- admin
|
||||||
- developer
|
- developer
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type TraefikOidc struct {
|
|||||||
jwtVerifier JWTVerifier
|
jwtVerifier JWTVerifier
|
||||||
excludedURLs map[string]struct{}
|
excludedURLs map[string]struct{}
|
||||||
allowedUserDomains map[string]struct{}
|
allowedUserDomains map[string]struct{}
|
||||||
|
allowedUsers map[string]struct{} // Map for case-insensitive lookup of allowed email addresses
|
||||||
allowedRolesAndGroups map[string]struct{}
|
allowedRolesAndGroups map[string]struct{}
|
||||||
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
|
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
|
||||||
// exchangeCodeForTokenFunc func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) // Replaced by interface
|
// exchangeCodeForTokenFunc func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) // Replaced by interface
|
||||||
@@ -404,6 +405,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
excludedURLs: createStringMap(config.ExcludedURLs),
|
excludedURLs: createStringMap(config.ExcludedURLs),
|
||||||
allowedUserDomains: createStringMap(config.AllowedUserDomains),
|
allowedUserDomains: createStringMap(config.AllowedUserDomains),
|
||||||
|
allowedUsers: createCaseInsensitiveStringMap(config.AllowedUsers),
|
||||||
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
|
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -1806,39 +1808,62 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedDomain checks if the domain part of the provided email address is present
|
// isAllowedDomain checks if the provided email address is authorized based on combined
|
||||||
// in the configured list of allowed domains (t.allowedUserDomains).
|
// checks against the allowed users list and the allowed domains list.
|
||||||
// If the allowed domains list is empty, all domains are considered allowed.
|
//
|
||||||
|
// Authorization rules:
|
||||||
|
// - If both allowedUsers and allowedUserDomains are empty, any user with a valid OIDC session is authorized.
|
||||||
|
// - If allowedUsers is not empty, a user is authorized if their email address is present in the allowedUsers list.
|
||||||
|
// - If allowedUserDomains is not empty, a user is authorized if their email's domain is present in the allowedUserDomains list.
|
||||||
|
// - If both allowedUsers and allowedUserDomains are configured, a user is authorized if either condition is met.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - email: The email address to check.
|
// - email: The email address to check.
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - true if the domain is allowed or if no domain restrictions are configured.
|
// - true if the user is authorized based on the rules above.
|
||||||
// - false if the email format is invalid or the domain is not in the allowed list.
|
// - false if the user is not authorized or if the email format is invalid.
|
||||||
func (t *TraefikOidc) isAllowedDomain(email string) bool {
|
func (t *TraefikOidc) isAllowedDomain(email string) bool {
|
||||||
if len(t.allowedUserDomains) == 0 {
|
// If both lists are empty, all users are allowed
|
||||||
return true // If no domains are specified, all are allowed
|
if len(t.allowedUserDomains) == 0 && len(t.allowedUsers) == 0 {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(email, "@")
|
// Check for specific user email (case-insensitive)
|
||||||
if len(parts) != 2 {
|
if len(t.allowedUsers) > 0 {
|
||||||
t.logger.Errorf("Invalid email format encountered: %s", email)
|
_, userAllowed := t.allowedUsers[strings.ToLower(email)]
|
||||||
return false // Invalid email format
|
if userAllowed {
|
||||||
|
t.logger.Debugf("Email %s is explicitly allowed in allowedUsers", email)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := parts[1]
|
// Check domain if there are domain restrictions
|
||||||
_, ok := t.allowedUserDomains[domain]
|
if len(t.allowedUserDomains) > 0 {
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.logger.Errorf("Invalid email format encountered: %s", email)
|
||||||
|
return false // Invalid email format
|
||||||
|
}
|
||||||
|
|
||||||
// Add explicit logging for better debugging
|
domain := parts[1]
|
||||||
if ok {
|
_, domainAllowed := t.allowedUserDomains[domain]
|
||||||
t.logger.Debugf("Email domain %s is allowed", domain)
|
|
||||||
} else {
|
if domainAllowed {
|
||||||
t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v",
|
t.logger.Debugf("Email domain %s is allowed", domain)
|
||||||
domain, keysFromMap(t.allowedUserDomains))
|
return true
|
||||||
|
} else {
|
||||||
|
t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v",
|
||||||
|
domain, keysFromMap(t.allowedUserDomains))
|
||||||
|
}
|
||||||
|
} else if len(t.allowedUsers) > 0 {
|
||||||
|
// If only specific users are allowed (no domains), and email wasn't in the list
|
||||||
|
t.logger.Debugf("Email %s is not in the allowed users list: %v",
|
||||||
|
email, keysFromMap(t.allowedUsers))
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok
|
// If we reach here, the user is not authorized
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get keys from a map for logging
|
// Helper function to get keys from a map for logging
|
||||||
@@ -1850,6 +1875,16 @@ func keysFromMap(m map[string]struct{}) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createCaseInsensitiveStringMap creates a map from a slice of strings where keys are lowercase
|
||||||
|
// for case-insensitive matching of email addresses
|
||||||
|
func createCaseInsensitiveStringMap(items []string) map[string]struct{} {
|
||||||
|
result := make(map[string]struct{})
|
||||||
|
for _, item := range items {
|
||||||
|
result[strings.ToLower(item)] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// extractGroupsAndRoles attempts to extract 'groups' and 'roles' claims from a decoded ID token.
|
// extractGroupsAndRoles attempts to extract 'groups' and 'roles' claims from a decoded ID token.
|
||||||
// It expects these claims, if present, to be arrays of strings.
|
// It expects these claims, if present, to be arrays of strings.
|
||||||
// It uses the configured extractClaimsFunc (which defaults to the package-level extractClaims)
|
// It uses the configured extractClaimsFunc (which defaults to the package-level extractClaims)
|
||||||
|
|||||||
+62
-13
@@ -1210,30 +1210,79 @@ func TestIsAllowedDomain(t *testing.T) {
|
|||||||
ts.Setup()
|
ts.Setup()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
email string
|
email string
|
||||||
allowed bool
|
allowedDomains map[string]struct{}
|
||||||
|
allowedUsers map[string]struct{}
|
||||||
|
allowed bool
|
||||||
|
expectedLogOutput string // For testing log messages
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Allowed domain",
|
name: "Allowed domain",
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
allowed: true,
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Disallowed domain",
|
name: "Disallowed domain",
|
||||||
email: "user@notallowed.com",
|
email: "user@notallowed.com",
|
||||||
allowed: false,
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid email",
|
name: "Invalid email",
|
||||||
email: "invalid-email",
|
email: "invalid-email",
|
||||||
allowed: false,
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Specific user is allowed regardless of domain",
|
||||||
|
email: "specific.user@otherdomain.com",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Case-insensitive email matching for specific user",
|
||||||
|
email: "Specific.User@otherdomain.com", // Mixed case
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}}, // Lowercase
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Only allowed users configured (no domains)",
|
||||||
|
email: "specific.user@otherdomain.com",
|
||||||
|
allowedDomains: map[string]struct{}{}, // Empty allowed domains
|
||||||
|
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User not in allowed list when only specific users configured",
|
||||||
|
email: "other.user@otherdomain.com",
|
||||||
|
allowedDomains: map[string]struct{}{}, // Empty allowed domains
|
||||||
|
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}},
|
||||||
|
allowed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No restrictions (both empty)",
|
||||||
|
email: "anyone@anydomain.com",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
allowed := ts.tOidc.isAllowedDomain(tc.email)
|
// Configure TraefikOidc instance for this test case
|
||||||
|
tOidc := ts.tOidc
|
||||||
|
tOidc.allowedUserDomains = tc.allowedDomains
|
||||||
|
tOidc.allowedUsers = tc.allowedUsers
|
||||||
|
|
||||||
|
allowed := tOidc.isAllowedDomain(tc.email)
|
||||||
if allowed != tc.allowed {
|
if allowed != tc.allowed {
|
||||||
t.Errorf("Expected allowed=%v, got %v", tc.allowed, allowed)
|
t.Errorf("Expected allowed=%v, got %v", tc.allowed, allowed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ type Config struct {
|
|||||||
// Example: ["company.com", "subsidiary.com"]
|
// Example: ["company.com", "subsidiary.com"]
|
||||||
AllowedUserDomains []string `json:"allowedUserDomains"`
|
AllowedUserDomains []string `json:"allowedUserDomains"`
|
||||||
|
|
||||||
|
// AllowedUsers restricts access to specific email addresses (optional)
|
||||||
|
// Example: ["user1@example.com", "user2@example.com"]
|
||||||
|
AllowedUsers []string `json:"allowedUsers"`
|
||||||
|
|
||||||
// AllowedRolesAndGroups restricts access to users with specific roles or groups (optional)
|
// AllowedRolesAndGroups restricts access to users with specific roles or groups (optional)
|
||||||
// Example: ["admin", "developer"]
|
// Example: ["admin", "developer"]
|
||||||
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
||||||
|
|||||||
@@ -202,6 +202,20 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expectedError: "",
|
expectedError: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Valid Config With AllowedUsers",
|
||||||
|
config: &Config{
|
||||||
|
ProviderURL: "https://provider.com",
|
||||||
|
CallbackURL: "/callback",
|
||||||
|
ClientID: "client-id",
|
||||||
|
ClientSecret: "client-secret",
|
||||||
|
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
||||||
|
LogLevel: "debug",
|
||||||
|
RateLimit: 100,
|
||||||
|
AllowedUsers: []string{"user1@example.com", "user2@example.com"},
|
||||||
|
},
|
||||||
|
expectedError: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user