fix: endless redirection loop between /user/settings/change_password and /user/settings/security (#10002)

Fixes forgejo/forgejo#9980

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Bug fixes
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/10002): <!--number 10002 --><!--line 0 --><!--description ZW5kbGVzcyByZWRpcmVjdGlvbiBsb29wIGJldHdlZW4gL3VzZXIvc2V0dGluZ3MvY2hhbmdlX3Bhc3N3b3JkIGFuZCAvdXNlci9zZXR0aW5ncy9zZWN1cml0eQ==-->endless redirection loop between /user/settings/change_password and /user/settings/security<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10002
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: zokki <zokki.softwareschmiede@gmail.com>
Co-committed-by: zokki <zokki.softwareschmiede@gmail.com>
(cherry picked from commit dc0a63efe2)
This commit is contained in:
zokki 2025-11-07 21:12:47 +01:00 committed by forgejo-backport-action
parent dfdcbaf194
commit 35d8e41852
3 changed files with 104 additions and 3 deletions

View file

@ -0,0 +1,37 @@
-
id: 2001
lower_name: user2001
name: user2001
full_name: "user2001"
email: user2001@example.com
keep_email_private: false
email_notifications_preference: onmention
passwd: ZogKvWdyEx:password
passwd_hash_algo: dummy
must_change_password: true
login_source: 0
login_name: user2001
type: 0
salt: ZogKvWdyEx
max_repo_creation: -1
is_active: true
is_admin: false
is_restricted: false
allow_git_hook: false
allow_import_local: false
allow_create_organization: true
prohibit_login: false
avatar: ""
avatar_email: user2001@example.com
use_custom_avatar: true
num_followers: 0
num_following: 0
num_stars: 0
num_repos: 0
num_teams: 0
num_members: 0
visibility: 0
repo_admin_change_team_access: false
theme: ""
keep_activity_private: false
created_unix: 1759086716

View file

@ -167,12 +167,14 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
return
}
} else if ctx.Req.URL.Path == "/user/settings/change_password" {
if ctx.Doer.MustHaveTwoFactor() {
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
// make sure that the form cannot be accessed by users who don't need this
ctx.Redirect(setting.AppSubURL + "/")
return
}
if ctx.Doer.MustHaveTwoFactor() && !strings.HasPrefix(ctx.Req.URL.Path, "/user/settings/security") {
} else if ctx.Doer.MustHaveTwoFactor() && !strings.HasPrefix(ctx.Req.URL.Path, "/user/settings/security") {
hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
log.Error("Error getting 2fa: %s", err)

View file

@ -17,6 +17,7 @@ import (
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/modules/translation"
"forgejo.org/services/forms"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
@ -276,3 +277,64 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
})
})
}
func TestTwoFactorWithPasswordChange(t *testing.T) {
defer unittest.OverrideFixtures("models/fixtures/TestTwoFactorWithPasswordChange")()
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
changePasswordUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{MustChangePassword: true})
runTest := func(t *testing.T, user *user_model.User, requireTOTP bool) {
t.Helper()
defer unittest.AssertSuccessfulDelete(t, &auth.TwoFactor{UID: user.ID})
session := loginUser(t, user.Name)
if user.MustChangePassword {
req := NewRequest(t, "GET", fmt.Sprintf("/%s", user.Name))
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/change_password", resp.Header().Get("Location"))
req = NewRequest(t, "GET", "/user/settings/security")
resp = session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/change_password", resp.Header().Get("Location"))
req = NewRequestWithJSON(t, "POST", "/user/settings/change_password", forms.MustChangePasswordForm{
Password: "password",
Retype: "password",
})
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
}
if requireTOTP {
req := NewRequest(t, "GET", fmt.Sprintf("/%s", user.Name))
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
req = NewRequest(t, "GET", "/user/settings/change_password")
resp = session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
session.EnrollTOTP(t)
}
req := NewRequest(t, "GET", fmt.Sprintf("/%s", user.Name))
session.MakeRequest(t, req, http.StatusOK)
}
t.Run("Don't require TwoFactor", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
runTest(t, normalUser, false)
runTest(t, changePasswordUser, false)
})
t.Run("Require TwoFactor", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
runTest(t, normalUser, true)
runTest(t, changePasswordUser, true)
})
}