diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md index 7dfdb33..396c4c1 100644 --- a/docs/documentation/configuration/examples.md +++ b/docs/documentation/configuration/examples.md @@ -144,6 +144,9 @@ auth: extra_scopes: - https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.profile + allowed_user_groups: + - the-admin-group + - vpn-users field_map: user_identifier: sub email: email @@ -201,6 +204,9 @@ auth: - email - profile - i-want-some-groups + allowed_user_groups: + - admin-group-name + - vpn-users field_map: email: email firstname: name diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 9fa1f84..8581082 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -561,6 +561,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`: - **Default:** *(empty)* - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. +#### `allowed_user_groups` +- **Default:** *(empty)* +- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values. + #### `field_map` - **Default:** *(empty)* - **Description:** Maps OIDC claims to WireGuard Portal user fields. @@ -639,6 +643,10 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`: - **Default:** *(empty)* - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. +#### `allowed_user_groups` +- **Default:** *(empty)* +- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values. + #### `field_map` - **Default:** *(empty)* - **Description:** Maps OAuth attributes to WireGuard Portal fields. diff --git a/docs/documentation/usage/authentication.md b/docs/documentation/usage/authentication.md index d02afeb..8902951 100644 --- a/docs/documentation/usage/authentication.md +++ b/docs/documentation/usage/authentication.md @@ -66,6 +66,40 @@ auth: - "outlook.com" ``` +#### Limiting Login to Specific User Groups + +You can limit the login to specific user groups by setting the `allowed_user_groups` property for OAuth2 or OIDC providers. +If this property is not empty, the user's `user_groups` claim must contain at least one matching group. + +To use this feature, ensure your group claim is mapped via `field_map.user_groups`. + +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + allowed_user_groups: + - "wg-users" + - "wg-admins" + field_map: + user_groups: "groups" +``` + +If `allowed_user_groups` is configured and the authenticated user has no matching group in `user_groups`, login is denied. + +Minimal deny-by-group example: + +```yaml +auth: + oauth: + - provider_name: "oauth1" + # ... other settings + allowed_user_groups: + - "vpn-users" + field_map: + user_groups: "groups" +``` + #### Limit Login to Existing Users You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers. diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index 4849b7f..bd2051c 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -65,6 +65,9 @@ type AuthenticatorOauth interface { RegistrationEnabled() bool // GetAllowedDomains returns the list of whitelisted domains GetAllowedDomains() []string + // GetAllowedUserGroups returns the list of whitelisted user groups. + // If non-empty, at least one user group must match. + GetAllowedUserGroups() []string } // AuthenticatorLdap is the interface for all LDAP authenticators. @@ -497,6 +500,33 @@ func isDomainAllowed(email string, allowedDomains []string) bool { return false } +func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool { + if len(allowedUserGroups) == 0 { + return true + } + + allowed := make(map[string]struct{}, len(allowedUserGroups)) + for _, group := range allowedUserGroups { + trimmed := strings.TrimSpace(group) + if trimmed == "" { + continue + } + allowed[trimmed] = struct{}{} + } + + if len(allowed) == 0 { + return false + } + + for _, group := range userGroups { + if _, ok := allowed[strings.TrimSpace(group)]; ok { + return true + } + } + + return false +} + // OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and // fetching the user information. func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) { @@ -524,6 +554,10 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email) } + if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) { + return nil, fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier) + } + ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), diff --git a/internal/app/auth/auth_oauth.go b/internal/app/auth/auth_oauth.go index 92b170f..87a7573 100644 --- a/internal/app/auth/auth_oauth.go +++ b/internal/app/auth/auth_oauth.go @@ -29,6 +29,7 @@ type PlainOauthAuthenticator struct { userInfoLogging bool sensitiveInfoLogging bool allowedDomains []string + allowedUserGroups []string } func newPlainOauthAuthenticator( @@ -60,6 +61,7 @@ func newPlainOauthAuthenticator( provider.userInfoLogging = cfg.LogUserInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.allowedDomains = cfg.AllowedDomains + provider.allowedUserGroups = cfg.AllowedUserGroups return provider, nil } @@ -73,6 +75,10 @@ func (p PlainOauthAuthenticator) GetAllowedDomains() []string { return p.allowedDomains } +func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string { + return p.allowedUserGroups +} + // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. func (p PlainOauthAuthenticator) RegistrationEnabled() bool { return p.registrationEnabled diff --git a/internal/app/auth/auth_oidc.go b/internal/app/auth/auth_oidc.go index 571fd40..4d774bd 100644 --- a/internal/app/auth/auth_oidc.go +++ b/internal/app/auth/auth_oidc.go @@ -26,6 +26,7 @@ type OidcAuthenticator struct { userInfoLogging bool sensitiveInfoLogging bool allowedDomains []string + allowedUserGroups []string } func newOidcAuthenticator( @@ -61,6 +62,7 @@ func newOidcAuthenticator( provider.userInfoLogging = cfg.LogUserInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.allowedDomains = cfg.AllowedDomains + provider.allowedUserGroups = cfg.AllowedUserGroups return provider, nil } @@ -74,6 +76,10 @@ func (o OidcAuthenticator) GetAllowedDomains() []string { return o.allowedDomains } +func (o OidcAuthenticator) GetAllowedUserGroups() []string { + return o.allowedUserGroups +} + // RegistrationEnabled returns whether registration is enabled for this authenticator. func (o OidcAuthenticator) RegistrationEnabled() bool { return o.registrationEnabled diff --git a/internal/app/auth/oauth_common.go b/internal/app/auth/oauth_common.go index a66b1ce..855823c 100644 --- a/internal/app/auth/oauth_common.go +++ b/internal/app/auth/oauth_common.go @@ -16,6 +16,7 @@ func parseOauthUserInfo( ) (*domain.AuthenticatorUserInfo, error) { var isAdmin bool var adminInfoAvailable bool + userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil) // first try to match the is_admin field against the given regex if mapping.IsAdmin != "" { @@ -29,7 +30,6 @@ func parseOauthUserInfo( // next try to parse the user's groups if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" { adminInfoAvailable = true - userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil) re := adminMapping.GetAdminGroupRegex() for _, group := range userGroups { if re.MatchString(strings.TrimSpace(group)) { @@ -42,6 +42,7 @@ func parseOauthUserInfo( userInfo := &domain.AuthenticatorUserInfo{ Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")), Email: internal.MapDefaultString(raw, mapping.Email, ""), + UserGroups: userGroups, Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""), Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""), Phone: internal.MapDefaultString(raw, mapping.Phone, ""), diff --git a/internal/app/auth/oauth_common_test.go b/internal/app/auth/oauth_common_test.go index 0bcca98..8c08210 100644 --- a/internal/app/auth/oauth_common_test.go +++ b/internal/app/auth/oauth_common_test.go @@ -96,6 +96,7 @@ func Test_parseOauthUserInfo_admin_group(t *testing.T) { assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Lastname, "") assert.Equal(t, info.Email, "test@mydomain.net") + assert.Equal(t, info.UserGroups, []string{"abuse@mydomain.net", "postmaster@mydomain.net", "wgportal-admins@mydomain.net"}) } func Test_parseOauthUserInfo_admin_value(t *testing.T) { diff --git a/internal/config/auth.go b/internal/config/auth.go index a3c6f5a..c91598e 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -258,6 +258,10 @@ type OpenIDConnectProvider struct { // AllowedDomains defines the list of allowed domains AllowedDomains []string `yaml:"allowed_domains"` + // AllowedUserGroups defines the list of allowed user groups. + // If not empty, at least one group from the user's group claim must match. + AllowedUserGroups []string `yaml:"allowed_user_groups"` + // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields FieldMap OauthFields `yaml:"field_map"` @@ -303,6 +307,10 @@ type OAuthProvider struct { // AllowedDomains defines the list of allowed domains AllowedDomains []string `yaml:"allowed_domains"` + // AllowedUserGroups defines the list of allowed user groups. + // If not empty, at least one group from the user's group claim must match. + AllowedUserGroups []string `yaml:"allowed_user_groups"` + // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields FieldMap OauthFields `yaml:"field_map"` diff --git a/internal/domain/auth.go b/internal/domain/auth.go index bed6219..02853b7 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -12,6 +12,7 @@ type LoginProviderInfo struct { type AuthenticatorUserInfo struct { Identifier UserIdentifier Email string + UserGroups []string Firstname string Lastname string Phone string