From a3caf21440e1e307148eda1f941e71660ab0ad92 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 27 Jul 2026 01:26:02 +0800 Subject: [PATCH] feat: admin impersonates a user (#38614) * fix #3631 * fix #21599 by the way, refactored the "profile avatar card" to simplify the code. --- modules/session/key.go | 5 +- options/locale/locale_en-US.json | 1 + routers/install/install.go | 8 +- routers/web/admin/users.go | 15 ++ routers/web/auth/auth.go | 41 ++-- routers/web/auth/linkaccount.go | 2 +- routers/web/auth/oauth.go | 5 +- routers/web/auth/openid.go | 2 +- routers/web/web.go | 4 +- services/auth/auth.go | 15 +- services/auth/impersonate.go | 62 +++++ services/auth/reverseproxy.go | 6 +- services/auth/session.go | 25 +- templates/admin/user/view.tmpl | 3 +- templates/base/head_navbar.tmpl | 6 +- templates/shared/user/profile_big_avatar.tmpl | 219 +++++++++--------- tests/integration/admin_user_test.go | 30 +++ web_src/css/user.css | 67 +----- 18 files changed, 278 insertions(+), 238 deletions(-) create mode 100644 services/auth/impersonate.go diff --git a/modules/session/key.go b/modules/session/key.go index c3da997c67..5a6b14ec40 100644 --- a/modules/session/key.go +++ b/modules/session/key.go @@ -4,8 +4,9 @@ package session const ( - KeyUID = "uid" - KeyUname = "uname" + KeyUID = "uid" + + KeyImpersonatorData = "impersonatorData" KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth" ) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 599690891b..ae14deee3a 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3038,6 +3038,7 @@ "admin.users.send_register_notify": "Send User Registration Notification", "admin.users.new_success": "The user account \"%s\" has been created.", "admin.users.edit": "Edit", + "admin.users.impersonate": "Impersonate", "admin.users.auth_source": "Authentication Source", "admin.users.local": "Local", "admin.users.auth_login_name": "Authentication Sign-In Name", diff --git a/routers/install/install.go b/routers/install/install.go index 8466caa2bf..5f46bc2770 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -23,6 +23,7 @@ import ( "gitea.dev/modules/graceful" "gitea.dev/modules/log" "gitea.dev/modules/optional" + "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/timeutil" @@ -502,15 +503,10 @@ func SubmitInstall(ctx *context.Context) { ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) // Auto-login for admin - if err = ctx.Session.Set("uid", u.ID); err != nil { + if err = ctx.Session.Set(session.KeyUID, u.ID); err != nil { ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } - if err = ctx.Session.Set("uname", u.Name); err != nil { - ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) - return - } - if err = ctx.Session.Release(); err != nil { ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index e5844350aa..c4b93202d1 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -26,6 +26,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/web/explore" user_setting "gitea.dev/routers/web/user/setting" + auth_service "gitea.dev/services/auth" "gitea.dev/services/context" "gitea.dev/services/forms" "gitea.dev/services/mailer" @@ -460,6 +461,20 @@ func EditUserPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))) } +func ImpersonateUser(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + ctx.JSONError("unable to get user") + return + } + err = auth_service.ImpersonateUser(ctx.Session, u) + if err != nil { + ctx.ServerError("unable to impersonate user", err) + return + } + ctx.JSONRedirect(setting.AppSubURL + "/user/settings") +} + // DeleteUser response for deleting a user func DeleteUser(ctx *context.Context) { u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 4c8a379ad0..644eede060 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -118,9 +118,8 @@ func autoSignIn(ctx *context.Context) (bool, error) { ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) - if err := regenerateSession(ctx, nil, map[string]any{ + if err := regenerateSession(ctx, map[string]any{ session.KeyUID: u.ID, - session.KeyUname: u.Name, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { return false, fmt.Errorf("unable to updateSession: %w", err) @@ -357,7 +356,7 @@ func SignInPost(ctx *context.Context) { // User will need to use WebAuthn, save data updates["totpEnrolled"] = u.ID } - if err := regenerateSession(ctx, nil, updates); err != nil { + if err := regenerateSession(ctx, updates); err != nil { ctx.ServerError("UserSignIn: Unable to update session", err) return } @@ -398,19 +397,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) { return } - if err := regenerateSession(ctx, []string{ - // Delete the openid, 2fa and link_account data - "openid_verified_uri", - "openid_signin_remember", - "openid_determined_email", - "openid_determined_username", - "twofaUid", - "twofaRemember", - "linkAccount", - "linkAccountData", - }, map[string]any{ + auth_service.ClearSessionKeysForSignIn(ctx.Session) + if err := regenerateSession(ctx, map[string]any{ session.KeyUID: u.ID, - session.KeyUname: u.Name, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { ctx.ServerError("RegenerateSession", err) @@ -477,6 +466,16 @@ func SignOut(ctx *context.Context) { }) } + exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session) + if err != nil { + ctx.ServerError("ExitImpersonatedUser", err) + return + } + if exitedImpersonated { + ctx.Redirect(setting.AppSubURL + "/-/admin") + return + } + // prepare the sign-out URL before destroying the session redirectTo := buildSignOutRedirectURL(ctx) HandleSignOut(ctx) @@ -884,10 +883,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { log.Trace("User activated: %s", user.Name) - if err := regenerateSession(ctx, nil, map[string]any{ - "uid": user.ID, - "uname": user.Name, - }); err != nil { + if err := regenerateSession(ctx, map[string]any{session.KeyUID: user.ID}); err != nil { log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) ctx.ServerError("ActivateUserEmail", err) return @@ -936,17 +932,12 @@ func ActivateEmail(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/account") } -func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error { +func regenerateSession(ctx *context.Context, updates map[string]any) error { if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil { return fmt.Errorf("regenerate session: %w", err) } sess := ctx.Session sessID := sess.ID() - for _, k := range deletes { - if err := sess.Delete(k); err != nil { - return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err) - } - } for k, v := range updates { if err := sess.Set(k, v); err != nil { return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err) diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index e2adac1bdd..93dd577d8c 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -169,7 +169,7 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData return } - if err := regenerateSession(ctx, nil, map[string]any{ + if err := regenerateSession(ctx, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, "twofaRemember": remember, diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index e861ed9637..e5dc1ac5ee 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -428,9 +428,8 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m return } - if err := regenerateSession(ctx, nil, map[string]any{ + if err := regenerateSession(ctx, map[string]any{ session.KeyUID: u.ID, - session.KeyUname: u.Name, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { ctx.ServerError("updateSession", err) @@ -453,7 +452,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m } } - if err := regenerateSession(ctx, nil, map[string]any{ + if err := regenerateSession(ctx, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, "twofaRemember": false, diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 9492462afa..cf9d8074cf 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) { if u != nil { nickname = u.LowerName } - if err := regenerateSession(ctx, nil, map[string]any{ + if err := regenerateSession(ctx, map[string]any{ "openid_verified_uri": id, "openid_determined_email": email, "openid_determined_username": nickname, diff --git a/routers/web/web.go b/routers/web/web.go index 0bfae44e86..b3f27c79f9 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -16,6 +16,7 @@ import ( "gitea.dev/modules/metrics" "gitea.dev/modules/public" "gitea.dev/modules/reqctx" + "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/storage" "gitea.dev/modules/structs" @@ -159,7 +160,7 @@ func newWebAuthMiddleware() *AuthMiddleware { ctx.IsBasicAuth = ar.IsBasicAuth if ctx.Doer == nil { // ensure the session uid is deleted - _ = ctx.Session.Delete("uid") + _ = ctx.Session.Delete(session.KeyUID) } } return webAuth @@ -795,6 +796,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost) m.Get("/{userid}", admin.ViewUser) m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost) + m.Post("/{userid}/impersonate", admin.ImpersonateUser) m.Post("/{userid}/delete", admin.DeleteUser) m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost) m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) diff --git a/services/auth/auth.go b/services/auth/auth.go index 01ceeeed46..97f29cdbf4 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -48,19 +48,8 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore sess = newSess } - _ = sess.Delete("openid_verified_uri") - _ = sess.Delete("openid_signin_remember") - _ = sess.Delete("openid_determined_email") - _ = sess.Delete("openid_determined_username") - _ = sess.Delete("twofaUid") - _ = sess.Delete("twofaRemember") - _ = sess.Delete("webauthnAssertion") - _ = sess.Delete("linkAccount") - err = sess.Set("uid", user.ID) - if err != nil { - log.Error(fmt.Sprintf("Error setting session: %v", err)) - } - err = sess.Set("uname", user.Name) + ClearSessionKeysForSignIn(sess) + err = sess.Set(session.KeyUID, user.ID) if err != nil { log.Error(fmt.Sprintf("Error setting session: %v", err)) } diff --git a/services/auth/impersonate.go b/services/auth/impersonate.go new file mode 100644 index 0000000000..e16efe897d --- /dev/null +++ b/services/auth/impersonate.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/session" +) + +func ImpersonateUser(sess SessionStore, u *user_model.User) error { + if sess.Get(session.KeyImpersonatorData) != nil { + return errors.New("already impersonating a user") + } + // TODO: in the future, we need to process all sessions keys, but the session store doesn't have the ability to list keys + // So we need to refactor all "Session.Get" to use consts, then we can enumerate the pre-defined keys. + backupKeys := []string{session.KeyUID, session.KeyUserHasTwoFactorAuth} + backup := map[string]any{} + for _, key := range backupKeys { + v := sess.Get(key) + if v != nil { + backup[key] = v + } + } + err := sess.Set(session.KeyImpersonatorData, backup) + if err != nil { + return fmt.Errorf("set impersonator data: %w", err) + } + + ClearSessionKeysForSignIn(sess) + data := map[string]any{} + data[session.KeyUID] = u.ID + data[session.KeyUserHasTwoFactorAuth] = true // since we are impersonating, we don't want to require 2FA for the impersonated user + for k, v := range data { + if err = sess.Set(k, v); err != nil { + return fmt.Errorf("set session data: %w", err) + } + } + return sess.Release() +} + +func ExitImpersonatedUser(sess SessionStore) (bool, error) { + impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any) + if !ok { + return false, nil + } + err := sess.Delete(session.KeyImpersonatorData) + if err != nil { + return false, fmt.Errorf("delete impersonator data: %w", err) + } + + ClearSessionKeysForSignIn(sess) + for k, v := range impersonatorData { + if err = sess.Set(k, v); err != nil { + return false, fmt.Errorf("set impersonator data: %w", err) + } + } + return true, sess.Release() +} diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index b08a447995..ff95329c9e 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -11,6 +11,7 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/log" "gitea.dev/modules/optional" + "gitea.dev/modules/session" "gitea.dev/modules/setting" gouuid "github.com/google/uuid" @@ -117,8 +118,9 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da } } - if r.CreateSession { - if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) { + if r.CreateSession && sess != nil { + sessionUID, ok := sess.Get(session.KeyUID).(int64) + if !ok || sessionUID != user.ID { handleSignIn(w, req, sess, user) } } diff --git a/services/auth/session.go b/services/auth/session.go index 03c3b48c80..835f18cc70 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -8,6 +8,7 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/log" + "gitea.dev/modules/session" ) // Ensure the struct implements the interface. @@ -32,20 +33,14 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto return nil, nil //nolint:nilnil // the auth method is not applicable } - // Get user ID - uid := sess.Get("uid") - if uid == nil { - return nil, nil //nolint:nilnil // the auth method is not applicable - } - log.Trace("Session Authorization: Found user[%d]", uid) - - id, ok := uid.(int64) + // Get session user ID + uid, ok := sess.Get(session.KeyUID).(int64) if !ok { return nil, nil //nolint:nilnil // the auth method is not applicable } // Get user object - user, err := user_model.GetUserByID(req.Context(), id) + user, err := user_model.GetUserByID(req.Context(), uid) if err != nil { if !user_model.IsErrUserNotExist(err) { log.Error("GetUserByID: %v", err) @@ -58,3 +53,15 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto log.Trace("Session Authorization: Logged in user %-v", user) return user, nil } + +func ClearSessionKeysForSignIn(sess SessionStore) { + _ = sess.Delete("openid_verified_uri") + _ = sess.Delete("openid_signin_remember") + _ = sess.Delete("openid_determined_email") + _ = sess.Delete("openid_determined_username") + _ = sess.Delete("twofaUid") + _ = sess.Delete("twofaRemember") + _ = sess.Delete("webauthnAssertion") + _ = sess.Delete("linkAccount") + _ = sess.Delete("linkAccountData") +} diff --git a/templates/admin/user/view.tmpl b/templates/admin/user/view.tmpl index bb1b4991d8..a86b59d23c 100644 --- a/templates/admin/user/view.tmpl +++ b/templates/admin/user/view.tmpl @@ -6,7 +6,8 @@

{{.Title}}
- {{ctx.Locale.Tr "admin.users.edit"}} + + {{ctx.Locale.Tr "admin.users.edit"}}

diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index cc7e4e6775..5d2c4b1ca5 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -86,10 +86,10 @@
-