diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 9196180d81..f522b9da28 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1488,15 +1488,19 @@ LEVEL = Info ;; ;; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled ;DEFAULT_EMAIL_NOTIFICATIONS = enabled -;; Disabled features for users, could be "deletion", "manage_ssh_keys","manage_gpg_keys" more features can be disabled in future +;; Disabled features for users could be "deletion", "manage_ssh_keys", "manage_gpg_keys", "manage_mfa", "manage_credentials" more features can be disabled in future ;; - deletion: a user cannot delete their own account ;; - manage_ssh_keys: a user cannot configure ssh keys ;; - manage_gpg_keys: a user cannot configure gpg keys +;; - manage_mfa: a user cannot configure mfa devices +;; - manage_credentials: a user cannot configure emails, passwords, or openid ;USER_DISABLED_FEATURES = -;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior. +;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be "deletion", "manage_ssh_keys", "manage_gpg_keys", "manage_mfa", "manage_credentials". This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior. ;; - deletion: a user cannot delete their own account ;; - manage_ssh_keys: a user cannot configure ssh keys ;; - manage_gpg_keys: a user cannot configure gpg keys +;; - manage_mfa: a user cannot configure mfa devices +;; - manage_credentials: a user cannot configure emails, passwords, or openid ;;EXTERNAL_USER_DISABLE_FEATURES = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 0c15a866b6..b38e53d4ab 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -517,14 +517,18 @@ And the following unique queues: - `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**: Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled - `DISABLE_REGULAR_ORG_CREATION`: **false**: Disallow regular (non-admin) users from creating organizations. -- `USER_DISABLED_FEATURES`: **_empty_** Disabled features for users, could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys` and more features can be added in future. +- `USER_DISABLED_FEATURES`: **_empty_** Disabled features for users, could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`, `manage_mfa`, `manage_credentials` and more features can be added in future. - `deletion`: User cannot delete their own account. - `manage_ssh_keys`: User cannot configure ssh keys. - `manage_gpg_keys`: User cannot configure gpg keys. -- `EXTERNAL_USER_DISABLE_FEATURES`: **_empty_**: Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior. + - `manage_mfa`: a User cannot configure mfa devices. + - `manage_credentials`: a user cannot configure emails, passwords, or openid +- `EXTERNAL_USER_DISABLE_FEATURES`: **_empty_**: Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`, `manage_mfa`, `manage_credentials`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior. - `deletion`: User cannot delete their own account. - `manage_ssh_keys`: User cannot configure ssh keys. - `manage_gpg_keys`: User cannot configure gpg keys. + - `manage_mfa`: a User cannot configure mfa devices. + - `manage_credentials`: a user cannot configure emails, passwords, or openid ## Security (`security`) diff --git a/models/user/user.go b/models/user/user.go index 5ac24f0977..2a3c1833b9 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1263,12 +1263,14 @@ func GetOrderByName() string { return "name" } -// IsFeatureDisabledWithLoginType checks if a user feature is disabled, taking into account the login type of the +// IsFeatureDisabledWithLoginType checks if a user features are disabled, taking into account the login type of the // user if applicable -func IsFeatureDisabledWithLoginType(user *User, feature string) bool { +func IsFeatureDisabledWithLoginType(user *User, features ...string) bool { // NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType - return (user != nil && user.LoginType > auth.Plain && setting.Admin.ExternalUserDisableFeatures.Contains(feature)) || - setting.Admin.UserDisabledFeatures.Contains(feature) + if user != nil && user.LoginType > auth.Plain { + return setting.Admin.ExternalUserDisableFeatures.Contains(features...) + } + return setting.Admin.UserDisabledFeatures.Contains(features...) } // DisabledFeaturesWithLoginType returns the set of user features disabled, taking into account the login type diff --git a/modules/container/set.go b/modules/container/set.go index 15779983fd..adb77dcac7 100644 --- a/modules/container/set.go +++ b/modules/container/set.go @@ -3,6 +3,8 @@ package container +import "maps" + type Set[T comparable] map[T]struct{} // SetOf creates a set and adds the specified elements to it. @@ -29,11 +31,15 @@ func (s Set[T]) AddMultiple(values ...T) { } } -// Contains determines whether a set contains the specified element. +// Contains determines whether a set contains the specified elements. // Returns true if the set contains the specified element; otherwise, false. -func (s Set[T]) Contains(value T) bool { - _, has := s[value] - return has +func (s Set[T]) Contains(values ...T) bool { + ret := true + for _, value := range values { + _, has := s[value] + ret = ret && has + } + return ret } // Remove removes the specified element. @@ -54,3 +60,12 @@ func (s Set[T]) Values() []T { } return keys } + +// Union constructs a new set that is the union of the provided sets +func (s Set[T]) Union(sets ...Set[T]) Set[T] { + newSet := maps.Clone(s) + for i := range sets { + maps.Copy(newSet, sets[i]) + } + return newSet +} diff --git a/modules/setting/admin.go b/modules/setting/admin.go index 8aebc76154..ca4e9b1d58 100644 --- a/modules/setting/admin.go +++ b/modules/setting/admin.go @@ -20,11 +20,13 @@ func loadAdminFrom(rootCfg ConfigProvider) { Admin.DisableRegularOrgCreation = sec.Key("DISABLE_REGULAR_ORG_CREATION").MustBool(false) Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled") Admin.UserDisabledFeatures = container.SetOf(sec.Key("USER_DISABLED_FEATURES").Strings(",")...) - Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...) + Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...).Union(Admin.UserDisabledFeatures) } const ( - UserFeatureDeletion = "deletion" - UserFeatureManageSSHKeys = "manage_ssh_keys" - UserFeatureManageGPGKeys = "manage_gpg_keys" + UserFeatureDeletion = "deletion" + UserFeatureManageSSHKeys = "manage_ssh_keys" + UserFeatureManageGPGKeys = "manage_gpg_keys" + UserFeatureManageMFA = "manage_mfa" + UserFeatureManageCredentials = "manage_credentials" ) diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index d4d56bfc57..df11729344 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" shared "code.gitea.io/gitea/routers/web/shared/secrets" @@ -74,6 +75,7 @@ func Secrets(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageType"] = "secrets" ctx.Data["PageIsSharedSettingsSecrets"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) sCtx, err := getSecretsCtx(ctx) if err != nil { diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 8ea7548e51..563f39f0c8 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -6,6 +6,7 @@ package setting import ( "errors" + "fmt" "net/http" "time" @@ -33,6 +34,11 @@ const ( // Account renders change user's password, user's email and user suicide page func Account(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials, setting.UserFeatureDeletion) && !setting.Service.EnableNotifyMail { + ctx.NotFound("Not Found", fmt.Errorf("account setting are not allowed to be changed")) + return + } + ctx.Data["Title"] = ctx.Tr("settings.account") ctx.Data["PageIsSettingsAccount"] = true ctx.Data["Email"] = ctx.Doer.Email @@ -45,9 +51,16 @@ func Account(ctx *context.Context) { // AccountPost response for change user's password func AccountPost(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.NotFound("Not Found", fmt.Errorf("password setting is not allowed to be changed")) + return + } + form := web.GetForm(ctx).(*forms.ChangePasswordForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.Doer.Email + ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail if ctx.HasError() { loadAccountData(ctx) @@ -89,9 +102,16 @@ func AccountPost(ctx *context.Context) { // EmailPost response for change user's email func EmailPost(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed")) + return + } + form := web.GetForm(ctx).(*forms.AddEmailForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.Doer.Email + ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail // Make email address primary. if ctx.FormString("_method") == "PRIMARY" { @@ -216,6 +236,10 @@ func EmailPost(ctx *context.Context) { // DeleteEmail response for delete user's email func DeleteEmail(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed")) + return + } email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) if err != nil || email == nil { ctx.ServerError("GetEmailAddressByID", err) @@ -241,6 +265,8 @@ func DeleteAccount(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.Doer.Email + ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { switch { diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index e3822ca988..356c2ea5de 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -9,6 +9,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" @@ -24,6 +25,7 @@ const ( func Applications(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.applications") ctx.Data["PageIsSettingsApplications"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) loadApplicationsData(ctx) @@ -35,6 +37,7 @@ func ApplicationsPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.NewAccessTokenForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsApplications"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) if ctx.HasError() { loadApplicationsData(ctx) diff --git a/routers/web/user/setting/block.go b/routers/web/user/setting/block.go index 94fc380cee..d419fb321b 100644 --- a/routers/web/user/setting/block.go +++ b/routers/web/user/setting/block.go @@ -6,6 +6,7 @@ package setting import ( "net/http" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" @@ -19,6 +20,7 @@ const ( func BlockedUsers(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("user.block.list") ctx.Data["PageIsSettingsBlockedUsers"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared_user.BlockedUsers(ctx, ctx.Doer) if ctx.Written() { diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 9e969e045d..c492715fb5 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -25,11 +25,17 @@ const ( // Keys render user's SSH/GPG public keys page func Keys(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("keys setting is not allowed to be changed")) + return + } + ctx.Data["Title"] = ctx.Tr("settings.ssh_gpg_keys") ctx.Data["PageIsSettingsKeys"] = true ctx.Data["DisableSSH"] = setting.SSH.Disabled ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) loadKeysData(ctx) @@ -44,6 +50,7 @@ func KeysPost(ctx *context.Context) { ctx.Data["DisableSSH"] = setting.SSH.Disabled ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) if ctx.HasError() { loadKeysData(ctx) diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go index 4132659495..50521c11c0 100644 --- a/routers/web/user/setting/packages.go +++ b/routers/web/user/setting/packages.go @@ -25,6 +25,7 @@ const ( func Packages(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.SetPackagesContext(ctx, ctx.Doer) @@ -34,6 +35,7 @@ func Packages(ctx *context.Context) { func PackagesRuleAdd(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.SetRuleAddContext(ctx) @@ -43,6 +45,7 @@ func PackagesRuleAdd(ctx *context.Context) { func PackagesRuleEdit(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.SetRuleEditContext(ctx, ctx.Doer) @@ -52,6 +55,7 @@ func PackagesRuleEdit(ctx *context.Context) { func PackagesRuleAddPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.PerformRuleAddPost( ctx, @@ -64,6 +68,7 @@ func PackagesRuleAddPost(ctx *context.Context) { func PackagesRuleEditPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.PerformRuleEditPost( ctx, @@ -76,6 +81,7 @@ func PackagesRuleEditPost(ctx *context.Context) { func PackagesRulePreview(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsSettingsPackages"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) shared.SetRulePreviewContext(ctx, ctx.Doer) diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index e5ff8570cf..554f6cd6ce 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -48,6 +48,8 @@ func Profile(ctx *context.Context) { ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) + ctx.HTML(http.StatusOK, tplSettingsProfile) } @@ -57,6 +59,7 @@ func ProfilePost(ctx *context.Context) { ctx.Data["PageIsSettingsProfile"] = true ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) if ctx.HasError() { ctx.HTML(http.StatusOK, tplSettingsProfile) @@ -182,6 +185,7 @@ func DeleteAvatar(ctx *context.Context) { func Organization(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.organization") ctx.Data["PageIsSettingsOrganization"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) opts := organization.FindOrgOptions{ ListOptions: db.ListOptions{ @@ -213,6 +217,7 @@ func Organization(ctx *context.Context) { func Repos(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.repos") ctx.Data["PageIsSettingsRepos"] = true + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories @@ -326,6 +331,7 @@ func Appearance(ctx *context.Context) { allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top } ctx.Data["AllThemes"] = allThemes + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) var hiddenCommentTypes *big.Int val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index cd09102369..74ffe169db 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -13,6 +13,7 @@ import ( "strings" "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" @@ -25,6 +26,11 @@ import ( // RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. func RegenerateScratchTwoFactor(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true @@ -55,6 +61,11 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { // DisableTwoFactor deletes the user's 2FA settings. func DisableTwoFactor(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true @@ -142,6 +153,11 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool { // EnrollTwoFactor shows the page where the user can enroll into 2FA. func EnrollTwoFactor(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true @@ -167,6 +183,11 @@ func EnrollTwoFactor(ctx *context.Context) { // EnrollTwoFactorPost handles enrolling the user into 2FA. func EnrollTwoFactorPost(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go index 8f788e1735..30eb6f63f8 100644 --- a/routers/web/user/setting/security/openid.go +++ b/routers/web/user/setting/security/openid.go @@ -17,6 +17,11 @@ import ( // OpenIDPost response for change user's openid func OpenIDPost(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.Error(http.StatusNotFound) + return + } + form := web.GetForm(ctx).(*forms.AddOpenIDForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true @@ -105,6 +110,11 @@ func settingsOpenIDVerify(ctx *context.Context) { // DeleteOpenID response for delete user's openid func DeleteOpenID(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.Error(http.StatusNotFound) + return + } + if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { ctx.ServerError("DeleteUserOpenID", err) return @@ -117,6 +127,11 @@ func DeleteOpenID(ctx *context.Context) { // ToggleOpenIDVisibility response for toggle visibility of user's openid func ToggleOpenIDVisibility(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.Error(http.StatusNotFound) + return + } + if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { ctx.ServerError("ToggleUserOpenIDVisibility", err) return diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go index 8d6859ab87..b44cb4dd49 100644 --- a/routers/web/user/setting/security/security.go +++ b/routers/web/user/setting/security/security.go @@ -25,6 +25,12 @@ const ( // Security render change user's password page and 2FA func Security(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, + setting.UserFeatureManageMFA, setting.UserFeatureManageCredentials) { + ctx.Error(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("settings.security") ctx.Data["PageIsSettingsSecurity"] = true @@ -40,6 +46,11 @@ func Security(ctx *context.Context) { // DeleteAccountLink delete a single account link func DeleteAccountLink(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { + ctx.Error(http.StatusNotFound) + return + } + id := ctx.FormInt64("id") if id <= 0 { ctx.Flash.Error("Account link id is not given") @@ -145,4 +156,5 @@ func loadSecurityData(ctx *context.Context) { return } ctx.Data["OpenIDs"] = openid + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) } diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 1b8d0171f5..aafc2f2a64 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -10,6 +10,7 @@ import ( "time" "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" wa "code.gitea.io/gitea/modules/auth/webauthn" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -23,6 +24,11 @@ import ( // WebAuthnRegister initializes the webauthn registration procedure func WebAuthnRegister(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm) if form.Name == "" { // Set name to the hexadecimal of the current time @@ -64,6 +70,11 @@ func WebAuthnRegister(ctx *context.Context) { // WebauthnRegisterPost receives the response of the security key func WebauthnRegisterPost(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + name, ok := ctx.Session.Get("webauthnName").(string) if !ok || name == "" { ctx.ServerError("Get webauthnName", errors.New("no webauthnName")) @@ -113,6 +124,11 @@ func WebauthnRegisterPost(ctx *context.Context) { // WebauthnDelete deletes an security key by id func WebauthnDelete(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { + ctx.Error(http.StatusNotFound) + return + } + form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { ctx.ServerError("GetWebAuthnCredentialByID", err) diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go index 4423b62781..3732ca27c0 100644 --- a/routers/web/user/setting/webhooks.go +++ b/routers/web/user/setting/webhooks.go @@ -7,6 +7,7 @@ import ( "net/http" "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" @@ -24,6 +25,7 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks" ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks" ctx.Data["Description"] = ctx.Tr("settings.hooks.desc") + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) if err != nil { diff --git a/templates/user/settings/account.tmpl b/templates/user/settings/account.tmpl index 2aaf8535d1..a7b2464069 100644 --- a/templates/user/settings/account.tmpl +++ b/templates/user/settings/account.tmpl @@ -4,7 +4,7 @@ {{ctx.Locale.Tr "settings.password"}}
- {{if or (.SignedUser.IsLocal) (.SignedUser.IsOAuth2)}} + {{if and (not ($.UserDisabledFeatures.Contains "manage_credentials")) (or (.SignedUser.IsLocal) (.SignedUser.IsOAuth2))}}
{{template "base/disable_form_autofill"}} {{.CsrfTokenHtml}} @@ -35,6 +35,7 @@ {{end}}
+ {{if not (and ($.UserDisabledFeatures.Contains "manage_credentials") (not $.EnableNotifyMail))}}

{{ctx.Locale.Tr "settings.manage_emails"}}

@@ -63,6 +64,7 @@ {{end}} + {{if not ($.UserDisabledFeatures.Contains "manage_credentials")}} {{range .Emails}}
{{if not .IsPrimary}} @@ -109,8 +111,12 @@
{{end}} + {{end}} + {{end}} + + {{if not ($.UserDisabledFeatures.Contains "manage_credentials")}}
{{.CsrfTokenHtml}} @@ -127,6 +133,7 @@
{{ctx.Locale.Tr "settings.can_not_add_email_activations_pending"}}
{{end}}
+ {{end}} {{if not ($.UserDisabledFeatures.Contains "deletion")}}

diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index c360944814..c6c15512ab 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -4,24 +4,30 @@ {{ctx.Locale.Tr "settings.profile"}} + {{if not (and ($.UserDisabledFeatures.Contains "manage_credentials" "deletion") (not $.EnableNotifyMail))}} {{ctx.Locale.Tr "settings.account"}} + {{end}} {{ctx.Locale.Tr "settings.appearance"}} + {{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}} {{ctx.Locale.Tr "settings.security"}} + {{end}} {{ctx.Locale.Tr "user.block.list"}} {{ctx.Locale.Tr "settings.applications"}} + {{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}} {{ctx.Locale.Tr "settings.ssh_gpg_keys"}} + {{end}} {{if .EnableActions}}
{{ctx.Locale.Tr "actions.actions"}} diff --git a/templates/user/settings/security/security.tmpl b/templates/user/settings/security/security.tmpl index aee0456b8f..d9403cfc26 100644 --- a/templates/user/settings/security/security.tmpl +++ b/templates/user/settings/security/security.tmpl @@ -1,11 +1,17 @@ {{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings security")}} + {{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
+ {{if not ($.UserDisabledFeatures.Contains "manage_mfa")}} {{template "user/settings/security/twofa" .}} {{template "user/settings/security/webauthn" .}} + {{end}} + {{if not ($.UserDisabledFeatures.Contains "manage_credentials")}} {{template "user/settings/security/accountlinks" .}} {{if .EnableOpenIDSignIn}} {{template "user/settings/security/openid" .}} {{end}} + {{end}}
+ {{end}} {{template "user/settings/layout_footer" .}} diff --git a/tests/integration/user_settings_test.go b/tests/integration/user_settings_test.go new file mode 100644 index 0000000000..2103c92d58 --- /dev/null +++ b/tests/integration/user_settings_test.go @@ -0,0 +1,405 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" +) + +// Validate that each navbar setting is correct. This checks that the +// appropriate context is passed everywhere the navbar is rendered +func assertNavbar(t *testing.T, doc *HTMLDoc) { + // Only show the account page if users can change their email notifications, delete themselves, or manage credentials + if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureDeletion, setting.UserFeatureManageCredentials) && !setting.Service.EnableNotifyMail { + doc.AssertElement(t, ".menu a[href='/user/settings/account']", false) + } else { + doc.AssertElement(t, ".menu a[href='/user/settings/account']", true) + } + + if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageMFA, setting.UserFeatureManageCredentials) { + doc.AssertElement(t, ".menu a[href='/user/settings/security']", false) + } else { + doc.AssertElement(t, ".menu a[href='/user/settings/security']", true) + } + + if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) { + doc.AssertElement(t, ".menu a[href='/user/settings/keys']", false) + } else { + doc.AssertElement(t, ".menu a[href='/user/settings/keys']", true) + } +} + +func WithDisabledFeatures(t *testing.T, features ...string) { + t.Helper() + + global := setting.Admin.UserDisabledFeatures + user := setting.Admin.ExternalUserDisableFeatures + + setting.Admin.UserDisabledFeatures = container.SetOf(features...) + setting.Admin.ExternalUserDisableFeatures = setting.Admin.UserDisabledFeatures + + t.Cleanup(func() { + setting.Admin.UserDisabledFeatures = global + setting.Admin.ExternalUserDisableFeatures = user + }) +} + +func TestUserSettingsAccount(t *testing.T) { + t.Run("all features enabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // account navbar should display + doc.AssertElement(t, ".menu a[href='/user/settings/account']", true) + + doc.AssertElement(t, "#password", true) + doc.AssertElement(t, "#email", true) + doc.AssertElement(t, "#delete-form", true) + }) + + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#password", false) + doc.AssertElement(t, "#email", false) + doc.AssertElement(t, "#delete-form", true) + }) + + t.Run("deletion disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureDeletion) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#password", true) + doc.AssertElement(t, "#email", true) + doc.AssertElement(t, "#delete-form", false) + }) + + t.Run("deletion, credentials and email notifications are disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + mail := setting.Service.EnableNotifyMail + setting.Service.EnableNotifyMail = false + defer func() { + setting.Service.EnableNotifyMail = mail + }() + + WithDisabledFeatures(t, setting.UserFeatureDeletion, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/account") + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsUpdatePassword(t *testing.T) { + t.Run("enabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{ + "_csrf": doc.GetCSRF(), + "old_password": "password", + "password": "password", + "retype": "password", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + }) + + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{ + "_csrf": doc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsUpdateEmail(t *testing.T) { + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", "/user/settings/account/email", map[string]string{ + "_csrf": doc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsDeleteEmail(t *testing.T) { + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", "/user/settings/account/email/delete", map[string]string{ + "_csrf": doc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsDelete(t *testing.T) { + t.Run("deletion disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureDeletion) + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/settings/account") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", "/user/settings/account/delete", map[string]string{ + "_csrf": doc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsAppearance(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/appearance") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsSecurity(t *testing.T) { + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/security") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#register-webauthn", true) + }) + + t.Run("mfa disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageMFA) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/security") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#register-webauthn", false) + }) + + t.Run("credentials and mfa disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageCredentials, setting.UserFeatureManageMFA) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/security") + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsApplications(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/applications") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsKeys(t *testing.T) { + t.Run("all enabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/keys") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#add-ssh-button", true) + doc.AssertElement(t, "#add-gpg-key-panel", true) + }) + + t.Run("ssh keys disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/keys") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#add-ssh-button", false) + doc.AssertElement(t, "#add-gpg-key-panel", true) + }) + + t.Run("gpg keys disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageGPGKeys) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/keys") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + + doc.AssertElement(t, "#add-ssh-button", true) + doc.AssertElement(t, "#add-gpg-key-panel", false) + }) + + t.Run("ssh & gpg keys disabled", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/keys") + _ = session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestUserSettingsSecrets(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/actions/secrets") + if setting.Actions.Enabled { + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + } else { + session.MakeRequest(t, req, http.StatusNotFound) + } +} + +func TestUserSettingsPackages(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/packages") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsPackagesRulesAdd(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/packages/rules/add") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsOrganization(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/organization") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/repos") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +} + +func TestUserSettingsBlockedUsers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/blocked_users") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) +}