From 5e1923c49e5f7e1390d1a53661354d9df4a4b1a6 Mon Sep 17 00:00:00 2001 From: Elisei Roca Date: Fri, 28 Nov 2025 18:14:38 +0100 Subject: [PATCH] Enable logout redirection for reverse proxy setups When authentication is handled externally by a reverse proxy or SSO provider, users can be redirected to an external logout URL or relative path defined on the reverse proxy. The reverse proxy or SSO provider must redirect back to Gitea for terminating the local session. --- custom/conf/app.example.ini | 7 ++++++ modules/setting/security.go | 2 ++ modules/templates/helper.go | 3 +++ routers/web/auth/auth.go | 4 +++ routers/web/web.go | 1 + templates/base/head_navbar.tmpl | 8 ++++-- tests/integration/signout_test.go | 41 ++++++++++++++++++++++++++++++- 7 files changed, 63 insertions(+), 3 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2ade8455909a9..d1178390a8075 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -468,6 +468,13 @@ INTERNAL_TOKEN = ;REVERSE_PROXY_AUTHENTICATION_EMAIL = X-WEBAUTH-EMAIL ;REVERSE_PROXY_AUTHENTICATION_FULL_NAME = X-WEBAUTH-FULLNAME ;; +;; URL or path that Gitea should redirect users to *before* performing its +;; own logout. Use this when logout is handled by a reverse proxy or SSO. +;; The external logout endpoint (reverse proxy / IdP) must then redirect +;; the user back to /user/logout so Gitea can terminate its local session +;; after the global SSO logout completes. +;REVERSE_PROXY_LOGOUT_REDIRECT = /mellon/logout?ReturnTo=/user/logout +;; ;; Interpret X-Forwarded-For header or the X-Real-IP header and set this as the remote IP for the request ;REVERSE_PROXY_LIMIT = 1 ;; diff --git a/modules/setting/security.go b/modules/setting/security.go index 153b6bc944ff5..d53097aaef79f 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -25,6 +25,7 @@ var ( ReverseProxyAuthEmail string ReverseProxyAuthFullName string ReverseProxyLimit int + ReverseProxyLogoutRedirect string ReverseProxyTrustedProxies []string MinPasswordLength int ImportLocalPaths bool @@ -121,6 +122,7 @@ func loadSecurityFrom(rootCfg ConfigProvider) { ReverseProxyAuthFullName = sec.Key("REVERSE_PROXY_AUTHENTICATION_FULL_NAME").MustString("X-WEBAUTH-FULLNAME") ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1) + ReverseProxyLogoutRedirect = sec.Key("REVERSE_PROXY_LOGOUT_REDIRECT").MustString("") ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",") if len(ReverseProxyTrustedProxies) == 0 { ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a7aa321811d55..a172389d99123 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -139,6 +139,9 @@ func NewFuncMap() template.FuncMap { "MermaidMaxSourceCharacters": func() int { return setting.MermaidMaxSourceCharacters }, + "ReverseProxyLogoutRedirect": func() string { + return setting.ReverseProxyLogoutRedirect + }, // ----------------------------------------------------------------- // render diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 2ccd1c71b5ce3..74bc6ed2a0066 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -416,6 +416,10 @@ func SignOut(ctx *context.Context) { }) } HandleSignOut(ctx) + if ctx.Req.Method == http.MethodGet { + ctx.Redirect(setting.AppSubURL + "/") + return + } ctx.JSONRedirect(setting.AppSubURL + "/") } diff --git a/routers/web/web.go b/routers/web/web.go index dd1b391c68983..d6c4d41e42b6e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -694,6 +694,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/recover_account", auth.ResetPasswdPost) m.Get("/forgot_password", auth.ForgotPasswd) m.Post("/forgot_password", auth.ForgotPasswdPost) + m.Get("/logout", auth.SignOut) m.Post("/logout", auth.SignOut) m.Get("/stopwatches", reqSignIn, user.GetStopwatches) m.Get("/search_candidates", optExploreSignIn, user.SearchCandidates) diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 8cb72d6f98e69..7b149a40c2181 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -55,7 +55,9 @@
- + {{svg "octicon-sign-out"}} {{ctx.Locale.Tr "sign_out"}} @@ -128,7 +130,9 @@ {{end}}
- + {{svg "octicon-sign-out"}} {{ctx.Locale.Tr "sign_out"}} diff --git a/tests/integration/signout_test.go b/tests/integration/signout_test.go index 7fd0b5c64a017..93fcf7b00b82a 100644 --- a/tests/integration/signout_test.go +++ b/tests/integration/signout_test.go @@ -5,12 +5,15 @@ package integration import ( "net/http" + "strings" "testing" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" ) -func TestSignOut(t *testing.T) { +func TestSignOut_Post(t *testing.T) { defer tests.PrepareTestEnv(t)() session := loginUser(t, "user2") @@ -22,3 +25,39 @@ func TestSignOut(t *testing.T) { req = NewRequest(t, "GET", "/user2/repo2") session.MakeRequest(t, req, http.StatusNotFound) } + +func TestSignOut_Get(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/logout") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + location := resp.Header().Get("Location") + if location != "/" { + t.Fatalf("expected redirect Location to '/', got %q", location) + } + + // try to view a private repo, should fail + req = NewRequest(t, "GET", "/user2/repo2") + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestSignOut_ReverseProxyLogoutRedirect(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + defer test.MockVariableValue(&setting.ReverseProxyLogoutRedirect, "/mellon/logout?ReturnTo=/user/logout")() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/") + resp := session.MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + + // check that the external URL is present in the logout button + if !strings.Contains(body, `href="/mellon/logout?ReturnTo=/user/logout"`) { + t.Fatalf("logout button does not point to REVERSE_PROXY_LOGOUT_REDIRECT when configured") + } +}