feat: add manage_password to user disable features (#10541)

Forgejo supports disabling features for users with the configuration
options `USER_DISABLED_FEATURES` and `EXTERNAL_USER_DISABLE_FEATURES`.

Add `manage_password` that prevents users from configuring passwords.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10541
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: hwipl <hwipl@noreply.codeberg.org>
Co-committed-by: hwipl <hwipl@noreply.codeberg.org>
This commit is contained in:
hwipl 2026-01-26 18:58:39 +01:00 committed by Gusted
commit c9f81315d6
6 changed files with 152 additions and 11 deletions

View file

@ -1561,15 +1561,17 @@ LEVEL = Info
;DEFAULT_EMAIL_NOTIFICATIONS = enabled
;; Send an email to all admins when a new user signs up to inform the admins about this act. Options: true, false
;SEND_NOTIFICATION_EMAIL_ON_NEW_USER = false
;; 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_password" 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_password: a user cannot configure their password
;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_password`. 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_password: a user cannot configure their password
;;EXTERNAL_USER_DISABLE_FEATURES =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -26,7 +26,8 @@ func loadAdminFrom(rootCfg ConfigProvider) {
}
const (
UserFeatureDeletion = "deletion"
UserFeatureManageSSHKeys = "manage_ssh_keys"
UserFeatureManageGPGKeys = "manage_gpg_keys"
UserFeatureDeletion = "deletion"
UserFeatureManageSSHKeys = "manage_ssh_keys"
UserFeatureManageGPGKeys = "manage_gpg_keys"
UserFeatureManagePassword = "manage_password"
)

View file

@ -46,6 +46,11 @@ func Account(ctx *context.Context) {
// AccountPost response for change user's password
func AccountPost(ctx *context.Context) {
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManagePassword) {
ctx.NotFound("Not Found", errors.New("password is not allowed to be changed"))
return
}
form := web.GetForm(ctx).(*forms.ChangePasswordForm)
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsAccount"] = true

View file

@ -4,7 +4,7 @@
{{ctx.Locale.Tr "settings.change_password"}}
</h4>
<div class="ui attached segment">
{{if or (.SignedUser.IsLocal) (.SignedUser.IsOAuth2)}}
{{if and (not ($.UserDisabledFeatures.Contains "manage_password")) (or (.SignedUser.IsLocal) (.SignedUser.IsOAuth2))}}
<form class="ui form ignore-dirty" action="{{AppSubUrl}}/user/settings/account" method="post">
{{template "base/disable_form_autofill"}}
{{if .SignedUser.IsPasswordSet}}

View file

@ -708,16 +708,23 @@ func VerifyJSONSchema(t testing.TB, resp *httptest.ResponseRecorder, schemaFile
func GetHTMLTitle(t testing.TB, session *TestSession, urlStr string) string {
t.Helper()
doc := getHTMLDoc(t, session, urlStr, http.StatusOK)
return doc.Find("head title").Text()
}
// getHTMLDoc gets HTMLDoc from url with expected status. Use status
// NoExpectedStatus to ignore status.
func getHTMLDoc(t testing.TB, session *TestSession, urlStr string, expectedStatus int) *HTMLDoc {
t.Helper()
req := NewRequest(t, "GET", urlStr)
var resp *httptest.ResponseRecorder
if session == nil {
resp = MakeRequest(t, req, http.StatusOK)
resp = MakeRequest(t, req, expectedStatus)
} else {
resp = session.MakeRequest(t, req, http.StatusOK)
resp = session.MakeRequest(t, req, expectedStatus)
}
doc := NewHTMLParser(t, resp.Body)
return doc.Find("head title").Text()
return NewHTMLParser(t, resp.Body)
}
func SortMailerMessages(msgs []*mailer.Message) {

View file

@ -0,0 +1,126 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
"forgejo.org/modules/container"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/tests"
)
// TestUserSettingsAccount tests the contents of a user's account settings
// with(out) disabled user features.
func TestUserSettingsAccount(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("all features enabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
doc := getHTMLDoc(t, loginUser(t, "user2"), "/user/settings/account", http.StatusOK)
doc.AssertElement(t, "#password", true)
doc.AssertElement(t, "#email", true)
doc.AssertElement(t, "#delete-form", true)
})
t.Run("password disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
disabled := container.SetOf(setting.UserFeatureManagePassword)
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, disabled)()
defer test.MockVariableValue(&setting.Admin.ExternalUserDisableFeatures, disabled)()
doc := getHTMLDoc(t, loginUser(t, "user2"), "/user/settings/account", http.StatusOK)
doc.AssertElement(t, "#password", false)
doc.AssertElement(t, "#email", true)
doc.AssertElement(t, "#delete-form", true)
})
t.Run("deletion disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
disabled := container.SetOf(setting.UserFeatureDeletion)
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, disabled)()
defer test.MockVariableValue(&setting.Admin.ExternalUserDisableFeatures, disabled)()
doc := getHTMLDoc(t, loginUser(t, "user2"), "/user/settings/account", http.StatusOK)
doc.AssertElement(t, "#password", true)
doc.AssertElement(t, "#email", true)
doc.AssertElement(t, "#delete-form", false)
})
t.Run("deletion, password disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
disabled := container.SetOf(
setting.UserFeatureDeletion,
setting.UserFeatureManagePassword,
)
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, disabled)()
defer test.MockVariableValue(&setting.Admin.ExternalUserDisableFeatures, disabled)()
doc := getHTMLDoc(t, loginUser(t, "user2"), "/user/settings/account", http.StatusOK)
doc.AssertElement(t, "#password", false)
doc.AssertElement(t, "#email", true)
doc.AssertElement(t, "#delete-form", false)
})
}
// TestUserSettingsUpdatePassword tests updating a user's password with(out)
// disabled user features.
func TestUserSettingsUpdatePassword(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("password enabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// changing password should work
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{
"old_password": "password",
"password": "password",
"retype": "password",
})
session.MakeRequest(t, req, http.StatusSeeOther)
})
t.Run("password disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
disabled := container.SetOf(setting.UserFeatureManagePassword)
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, disabled)()
defer test.MockVariableValue(&setting.Admin.ExternalUserDisableFeatures, disabled)()
// changing password should not work
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{
"old_password": "password",
"password": "password",
"retype": "password",
})
session.MakeRequest(t, req, http.StatusNotFound)
})
}
// TestUserSettingsDelete tests deleting a user with(out) disabled user
// features.
func TestUserSettingsDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("deletion disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
disabled := container.SetOf(setting.UserFeatureDeletion)
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, disabled)()
defer test.MockVariableValue(&setting.Admin.ExternalUserDisableFeatures, disabled)()
// deleting user should not work
session := loginUser(t, "user2")
req := NewRequest(t, "POST", "/user/settings/account/delete")
session.MakeRequest(t, req, http.StatusNotFound)
})
}