From 23fd5f9e72e25326c193afc19ea7212cf59ff358 Mon Sep 17 00:00:00 2001 From: Elisei Roca Date: Thu, 4 Dec 2025 15:30:07 +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 to fully logout. --- custom/conf/app.example.ini | 5 ++++ modules/setting/security.go | 2 ++ routers/web/auth/auth.go | 8 ++++++ routers/web/web.go | 1 + services/context/context.go | 2 ++ templates/base/head_navbar.tmpl | 4 +-- tests/integration/signout_test.go | 43 ++++++++++++++++++++++++++++++- 7 files changed, 62 insertions(+), 3 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2ade8455909a9..6de6dddd8beb7 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -463,6 +463,11 @@ INTERNAL_TOKEN = ;; Name of cookie used to store authentication information. ;COOKIE_REMEMBER_NAME = gitea_incredible ;; +;; URL or path that Gitea should redirect users to *after* performing its own logout. +;; Use this to redirect user to the external logout endpoint, if needed, when authentication is handled by a reverse proxy or SSO. +;; Mellon example: REVERSE_PROXY_LOGOUT_REDIRECT = /mellon/logout?ReturnTo=/ +;REVERSE_PROXY_LOGOUT_REDIRECT = +;; ;; Reverse proxy authentication header name of user name, email, and full name ;REVERSE_PROXY_AUTHENTICATION_USER = X-WEBAUTH-USER ;REVERSE_PROXY_AUTHENTICATION_EMAIL = X-WEBAUTH-EMAIL 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/routers/web/auth/auth.go b/routers/web/auth/auth.go index 2ccd1c71b5ce3..57899ba55e05a 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -416,6 +416,14 @@ func SignOut(ctx *context.Context) { }) } HandleSignOut(ctx) + if setting.ReverseProxyLogoutRedirect != "" { + ctx.Redirect(setting.ReverseProxyLogoutRedirect) + return + } + 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 89a570dce0773..84c37c99e5b2a 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/services/context/context.go b/services/context/context.go index 26b5bd3775b7a..66c343937951d 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -206,6 +206,8 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["ManifestData"] = setting.ManifestData ctx.Data["AllLangs"] = translation.AllLangs() + ctx.Data["ReverseProxyLogoutRedirect"] = setting.ReverseProxyLogoutRedirect != "" + next.ServeHTTP(ctx.Resp, ctx.Req) }) } diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 8cb72d6f98e69..8bf22b0ba2d49 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -55,7 +55,7 @@
- + {{svg "octicon-sign-out"}} {{ctx.Locale.Tr "sign_out"}} @@ -128,7 +128,7 @@ {{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..8f3457a072aa0 100644 --- a/tests/integration/signout_test.go +++ b/tests/integration/signout_test.go @@ -7,10 +7,12 @@ import ( "net/http" "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 +24,42 @@ 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=/")() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user/logout") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + expected := "/mellon/logout?ReturnTo=/" + loc := resp.Header().Get("Location") + if loc != expected { + t.Fatalf("expected redirect to %q, got %q", expected, loc) + } + + // try to view a private repo, should fail + req = NewRequest(t, "GET", "/user2/repo2") + session.MakeRequest(t, req, http.StatusNotFound) +}