From 5cccbf510f5123ce039f052d58a317013fb1faa3 Mon Sep 17 00:00:00 2001 From: caojia <1301869380@qq.com> Date: Wed, 4 Nov 2026 15:20:05 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E4=BB=A3=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/SysImpersonationController.java | 80 +++++++ .../controller/system/SysLoginController.java | 1 + .../system/SysImpersonationTest.java | 209 ++++++++++++++++++ .../common/core/domain/model/LoginUser.java | 18 ++ .../com/bms/framework/aspectj/LogAspect.java | 7 +- .../filter/JwtAuthenticationTokenFilter.java | 49 +++- .../framework/web/service/TokenService.java | 36 ++- ...20261104_user_impersonation_permission.sql | 31 +++ bms-front/src/api/system/user.js | 9 + bms-front/src/layout/components/Navbar.vue | 21 +- bms-front/src/layout/index.vue | 6 +- bms-front/src/permission.js | 3 +- bms-front/src/store/modules/tagsView.js | 5 +- bms-front/src/store/modules/user.js | 21 +- bms-front/src/utils/auth.js | 28 ++- bms-front/src/utils/request.js | 13 +- bms-front/src/views/system/user/index.vue | 21 +- 17 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysImpersonationController.java create mode 100644 bms-back/bms-admin/src/test/java/com/bms/web/controller/system/SysImpersonationTest.java create mode 100644 bms-back/sql/system/20261104_user_impersonation_permission.sql diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysImpersonationController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysImpersonationController.java new file mode 100644 index 00000000..4bbf0964 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysImpersonationController.java @@ -0,0 +1,80 @@ +package com.bms.web.controller.system; + +import com.bms.common.annotation.Log; +import com.bms.common.constant.Constants; +import com.bms.common.core.domain.AjaxResult; +import com.bms.common.core.domain.entity.SysUser; +import com.bms.common.core.domain.model.LoginUser; +import com.bms.common.enums.BusinessType; +import com.bms.common.enums.UserStatus; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.SecurityUtils; +import com.bms.framework.web.service.TokenService; +import com.bms.framework.web.service.UserDetailsServiceImpl; +import com.bms.system.service.ISysUserService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 管理员以目标用户身份排查问题。 */ +@RestController +@RequestMapping("/system/user/impersonation") +public class SysImpersonationController { + private static final long MAX_DURATION_MILLIS = 30 * 60 * 1000L; + + private final ISysUserService userService; + private final UserDetailsServiceImpl userDetailsService; + private final TokenService tokenService; + + public SysImpersonationController(ISysUserService userService, UserDetailsServiceImpl userDetailsService, + TokenService tokenService) { + this.userService = userService; + this.userDetailsService = userDetailsService; + this.tokenService = tokenService; + } + + @PreAuthorize("@ss.hasPermi('system:user:impersonate')") + @Log(title = "用户代登录", businessType = BusinessType.GRANT, isSaveResponseData = false) + @PostMapping("/{userId}") + public AjaxResult start(@PathVariable Long userId) { + LoginUser operator = SecurityUtils.getLoginUser(); + // 项目中超级管理员是 user_id=1;代登录不能嵌套,也不能用于进入管理员账号。 + if (!SecurityUtils.isAdmin() || operator.isImpersonating()) { + throw new ServiceException("仅超级管理员可以代登录"); + } + if (userId == null || SecurityUtils.isAdmin(userId) || userId.equals(operator.getUserId())) { + throw new ServiceException("不能代登录管理员账号或当前账号"); + } + userService.checkUserDataScope(userId); + SysUser target = userService.selectUserById(userId); + if (target == null || !UserStatus.OK.getCode().equals(target.getDelFlag()) + || !UserStatus.OK.getCode().equals(target.getStatus())) { + throw new ServiceException("目标用户不存在、已删除或已停用"); + } + if (target.getRoles() != null && target.getRoles().stream().anyMatch(role -> role != null + && (role.isAdmin() || Constants.SUPER_ADMIN.equals(role.getRoleKey())))) { + throw new ServiceException("不能代登录管理员角色账号"); + } + LoginUser actingUser = (LoginUser) userDetailsService.createLoginUser(target); + actingUser.setImpersonatorId(operator.getUserId()); + actingUser.setImpersonatorName(operator.getUsername()); + actingUser.setImpersonatorToken(operator.getToken()); + actingUser.setImpersonationExpireTime(System.currentTimeMillis() + MAX_DURATION_MILLIS); + AjaxResult result = AjaxResult.success(); + result.put(Constants.TOKEN, tokenService.createToken(actingUser)); + return result; + } + + @Log(title = "退出用户代登录", businessType = BusinessType.OTHER, isSaveResponseData = false) + @PostMapping("/exit") + public AjaxResult exit() { + LoginUser user = SecurityUtils.getLoginUser(); + if (!user.isImpersonating()) { + throw new ServiceException("当前不是代登录会话"); + } + tokenService.delLoginUser(user.getToken()); + return AjaxResult.success(); + } +} diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysLoginController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysLoginController.java index 71edb527..d1721561 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysLoginController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/system/SysLoginController.java @@ -87,6 +87,7 @@ public class SysLoginController ajax.put("user", user); ajax.put("roles", roles); ajax.put("permissions", permissions); + ajax.put("impersonating", loginUser.isImpersonating()); ajax.put("pwdChrtype", getSysAccountChrtype()); ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate())); ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate())); diff --git a/bms-back/bms-admin/src/test/java/com/bms/web/controller/system/SysImpersonationTest.java b/bms-back/bms-admin/src/test/java/com/bms/web/controller/system/SysImpersonationTest.java new file mode 100644 index 00000000..77ae1c6d --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/web/controller/system/SysImpersonationTest.java @@ -0,0 +1,209 @@ +package com.bms.web.controller.system; + +import com.bms.common.constant.CacheConstants; +import com.bms.common.constant.Constants; +import com.bms.common.core.domain.AjaxResult; +import com.bms.common.core.domain.entity.SysUser; +import com.bms.common.core.domain.entity.SysRole; +import com.bms.common.core.domain.model.LoginUser; +import com.bms.common.core.redis.RedisCache; +import com.bms.common.exception.ServiceException; +import com.bms.framework.web.service.TokenService; +import com.bms.framework.web.service.UserDetailsServiceImpl; +import com.bms.framework.config.FastJson2JsonRedisSerializer; +import com.bms.system.service.ISysUserService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SysImpersonationTest { + private final ISysUserService users = mock(ISysUserService.class); + private final UserDetailsServiceImpl details = mock(UserDetailsServiceImpl.class); + private final TokenService tokens = mock(TokenService.class); + private final SysImpersonationController controller = new SysImpersonationController(users, details, tokens); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void onlySuperAdministratorCanStart() { + authenticate(loginUser(2L, "sales")); + assertThrows(ServiceException.class, () -> controller.start(3L)); + verifyNoInteractions(users, details, tokens); + } + + @Test + void rejectsDisabledAndAdministratorTargets() { + authenticate(loginUser(1L, "admin")); + SysUser disabled = user(2L, "sales"); + disabled.setStatus("1"); + when(users.selectUserById(2L)).thenReturn(disabled); + assertThrows(ServiceException.class, () -> controller.start(2L)); + assertThrows(ServiceException.class, () -> controller.start(1L)); + verifyNoInteractions(details, tokens); + } + + @Test + void issuedSessionUsesTargetIdentityAndRetainsAdministrator() { + LoginUser admin = loginUser(1L, "admin"); + admin.setToken("parent-session"); + authenticate(admin); + SysUser target = user(2L, "sales"); + LoginUser targetLogin = new LoginUser(2L, 10L, target, Collections.singleton("bill:list")); + when(users.selectUserById(2L)).thenReturn(target); + when(details.createLoginUser(target)).thenReturn(targetLogin); + when(tokens.createToken(targetLogin)).thenReturn("target-jwt"); + long started = System.currentTimeMillis(); + + AjaxResult result = controller.start(2L); + + assertEquals("target-jwt", result.get(Constants.TOKEN)); + assertEquals(2L, targetLogin.getUserId()); + assertEquals(1L, targetLogin.getImpersonatorId()); + assertEquals("parent-session", targetLogin.getImpersonatorToken()); + assertTrue(targetLogin.getImpersonationExpireTime() >= started + 29 * 60_000L); + assertTrue(targetLogin.getImpersonationExpireTime() <= System.currentTimeMillis() + 30 * 60_000L); + verify(users).checkUserDataScope(2L); + } + + @Test + void rejectsTargetWithAdministratorRole() { + authenticate(loginUser(1L, "admin")); + SysUser target = user(2L, "other-admin"); + SysRole role = new SysRole(); + role.setRoleId(1L); + target.setRoles(Collections.singletonList(role)); + when(users.selectUserById(2L)).thenReturn(target); + assertThrows(ServiceException.class, () -> controller.start(2L)); + verifyNoInteractions(details, tokens); + } + + @Test + void impersonationContextSurvivesRedisSerialization() { + LoginUser acting = loginUser(2L, "sales"); + acting.setImpersonatorId(1L); + acting.setImpersonatorName("admin"); + acting.setImpersonatorToken("parent"); + acting.setImpersonationExpireTime(System.currentTimeMillis() + 60_000L); + FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer<>(Object.class); + LoginUser restored = (LoginUser) serializer.deserialize(serializer.serialize(acting)); + assertTrue(restored.isImpersonating()); + assertEquals(1L, restored.getImpersonatorId()); + assertEquals("parent", restored.getImpersonatorToken()); + assertEquals(acting.getImpersonationExpireTime(), restored.getImpersonationExpireTime()); + } + + @Test + void rejectsSessionWhenAdministratorSessionWasRevoked() { + RedisCache redis = mock(RedisCache.class); + TokenService service = tokenService(redis); + LoginUser acting = loginUser(2L, "sales"); + acting.setImpersonatorId(1L); + acting.setImpersonatorToken("parent"); + acting.setImpersonationExpireTime(System.currentTimeMillis() + 60_000L); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "acting")).thenReturn(acting); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "parent")).thenReturn(null); + + assertNull(service.getLoginUser(request("acting"))); + verify(redis).deleteObject(CacheConstants.LOGIN_TOKEN_KEY + "acting"); + } + + @Test + void rejectsExpiredSessionEvenWhenAdministratorIsStillLoggedIn() { + RedisCache redis = mock(RedisCache.class); + TokenService service = tokenService(redis); + LoginUser acting = loginUser(2L, "sales"); + acting.setImpersonatorId(1L); + acting.setImpersonatorToken("parent"); + acting.setImpersonationExpireTime(System.currentTimeMillis() - 1); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "acting")).thenReturn(acting); + when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "parent")).thenReturn(loginUser(1L, "admin")); + + assertNull(service.getLoginUser(request("acting"))); + verify(redis).deleteObject(CacheConstants.LOGIN_TOKEN_KEY + "acting"); + } + + @Test + void exitRevokesOnlyTheImpersonationSession() { + LoginUser acting = loginUser(2L, "sales"); + acting.setToken("acting"); + acting.setImpersonatorId(1L); + acting.setImpersonatorToken("parent"); + authenticate(acting); + + assertEquals(200, controller.exit().get("code")); + verify(tokens).delLoginUser("acting"); + verify(tokens, never()).delLoginUser("parent"); + } + + @Test + void refreshCannotExtendPastAbsoluteDeadline() { + RedisCache redis = mock(RedisCache.class); + TokenService service = tokenService(redis); + LoginUser acting = loginUser(2L, "sales"); + acting.setToken("acting"); + acting.setImpersonatorId(1L); + long deadline = System.currentTimeMillis() + 45_000L; + acting.setImpersonationExpireTime(deadline); + + service.refreshToken(acting); + + assertEquals(deadline, acting.getExpireTime()); + ArgumentCaptor ttl = ArgumentCaptor.forClass(Integer.class); + verify(redis).setCacheObject(eq(CacheConstants.LOGIN_TOKEN_KEY + "acting"), same(acting), ttl.capture(), eq(TimeUnit.SECONDS)); + assertTrue(ttl.getValue() > 0 && ttl.getValue() <= 45); + } + + private static final String SECRET = "test-secret-for-impersonation-verification-012345678901234567890123456789"; + + private TokenService tokenService(RedisCache redis) { + TokenService service = new TokenService(); + ReflectionTestUtils.setField(service, "redisCache", redis); + ReflectionTestUtils.setField(service, "header", "Authorization"); + ReflectionTestUtils.setField(service, "secret", SECRET); + ReflectionTestUtils.setField(service, "expireTime", 1440); + return service; + } + + private MockHttpServletRequest request(String uuid) { + String jwt = Jwts.builder().setClaims(Map.of(Constants.LOGIN_USER_KEY, uuid)) + .signWith(SignatureAlgorithm.HS512, SECRET).compact(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + jwt); + return request; + } + + private LoginUser loginUser(long id, String name) { + SysUser user = user(id, name); + return new LoginUser(id, 10L, user, Collections.emptySet()); + } + + private SysUser user(long id, String name) { + SysUser user = new SysUser(id); + user.setUserName(name); + user.setStatus("0"); + user.setDelFlag("0"); + return user; + } + + private void authenticate(LoginUser user) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())); + } +} diff --git a/bms-back/bms-common/src/main/java/com/bms/common/core/domain/model/LoginUser.java b/bms-back/bms-common/src/main/java/com/bms/common/core/domain/model/LoginUser.java index c895cfea..1875b8a9 100644 --- a/bms-back/bms-common/src/main/java/com/bms/common/core/domain/model/LoginUser.java +++ b/bms-back/bms-common/src/main/java/com/bms/common/core/domain/model/LoginUser.java @@ -75,6 +75,24 @@ public class LoginUser implements UserDetails */ private SysUser user; + /** 代登录的实际操作人及其原会话标识,仅保存在服务端登录态中。 */ + private Long impersonatorId; + private String impersonatorName; + private String impersonatorToken; + private Long impersonationExpireTime; + + public Long getImpersonatorId() { return impersonatorId; } + public void setImpersonatorId(Long impersonatorId) { this.impersonatorId = impersonatorId; } + public String getImpersonatorName() { return impersonatorName; } + public void setImpersonatorName(String impersonatorName) { this.impersonatorName = impersonatorName; } + public String getImpersonatorToken() { return impersonatorToken; } + public void setImpersonatorToken(String impersonatorToken) { this.impersonatorToken = impersonatorToken; } + public Long getImpersonationExpireTime() { return impersonationExpireTime; } + public void setImpersonationExpireTime(Long impersonationExpireTime) { this.impersonationExpireTime = impersonationExpireTime; } + + @JSONField(serialize = false) + public boolean isImpersonating() { return impersonatorId != null; } + public LoginUser() { } diff --git a/bms-back/bms-framework/src/main/java/com/bms/framework/aspectj/LogAspect.java b/bms-back/bms-framework/src/main/java/com/bms/framework/aspectj/LogAspect.java index f49c2f09..2602fbc0 100644 --- a/bms-back/bms-framework/src/main/java/com/bms/framework/aspectj/LogAspect.java +++ b/bms-back/bms-framework/src/main/java/com/bms/framework/aspectj/LogAspect.java @@ -101,7 +101,7 @@ public class LogAspect operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255)); if (loginUser != null) { - operLog.setOperName(loginUser.getUsername()); + operLog.setOperName(loginUser.isImpersonating() ? loginUser.getImpersonatorName() : loginUser.getUsername()); SysUser currentUser = loginUser.getUser(); if (StringUtils.isNotNull(currentUser) && StringUtils.isNotNull(currentUser.getDept())) { @@ -122,6 +122,11 @@ public class LogAspect operLog.setRequestMethod(ServletUtils.getRequest().getMethod()); // 处理设置注解上的参数 getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult); + if (loginUser != null && loginUser.isImpersonating()) + { + String target = "代登录目标=" + loginUser.getUsername() + "(ID=" + loginUser.getUserId() + "); "; + operLog.setOperParam(StringUtils.substring(target + StringUtils.defaultString(operLog.getOperParam()), 0, PARAM_MAX_LENGTH)); + } // 设置消耗时间 operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get()); // 保存数据库 diff --git a/bms-back/bms-framework/src/main/java/com/bms/framework/security/filter/JwtAuthenticationTokenFilter.java b/bms-back/bms-framework/src/main/java/com/bms/framework/security/filter/JwtAuthenticationTokenFilter.java index 0f3f3f61..a22c33d0 100644 --- a/bms-back/bms-framework/src/main/java/com/bms/framework/security/filter/JwtAuthenticationTokenFilter.java +++ b/bms-back/bms-framework/src/main/java/com/bms/framework/security/filter/JwtAuthenticationTokenFilter.java @@ -11,10 +11,16 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.bms.common.core.domain.model.LoginUser; import com.bms.common.utils.SecurityUtils; import com.bms.common.utils.StringUtils; import com.bms.framework.web.service.TokenService; +import com.bms.framework.manager.AsyncManager; +import com.bms.framework.manager.factory.AsyncFactory; +import com.bms.system.domain.SysOperLog; +import com.bms.common.utils.ip.IpUtils; /** * token过滤器 验证token有效性 @@ -24,6 +30,7 @@ import com.bms.framework.web.service.TokenService; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class); @Autowired private TokenService tokenService; @@ -39,6 +46,46 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } - chain.doFilter(request, response); + if (loginUser == null || !loginUser.isImpersonating()) + { + chain.doFilter(request, response); + return; + } + long started = System.currentTimeMillis(); + boolean failed = false; + try + { + chain.doFilter(request, response); + } + catch (IOException | ServletException | RuntimeException e) + { + failed = true; + throw e; + } + finally + { + // 记录所有代登录请求的真实操作人和目标账号,不记录令牌、请求参数或响应正文。 + try + { + SysOperLog accessLog = new SysOperLog(); + accessLog.setTitle("代登录访问"); + accessLog.setBusinessType(0); + accessLog.setOperatorType(1); + accessLog.setOperName(loginUser.getImpersonatorName()); + accessLog.setOperIp(IpUtils.getIpAddr(request)); + accessLog.setOperUrl(StringUtils.substring(request.getRequestURI(), 0, 255)); + accessLog.setRequestMethod(request.getMethod()); + accessLog.setMethod("impersonation"); + accessLog.setOperParam("实际管理员ID=" + loginUser.getImpersonatorId() + ", 目标用户=" + + loginUser.getUsername() + "(ID=" + loginUser.getUserId() + "), HTTP状态=" + response.getStatus()); + accessLog.setStatus(failed || response.getStatus() >= 400 ? 1 : 0); + accessLog.setCostTime(System.currentTimeMillis() - started); + AsyncManager.me().execute(AsyncFactory.recordOper(accessLog)); + } + catch (Exception e) + { + log.error("记录代登录访问日志失败", e); + } + } } } diff --git a/bms-back/bms-framework/src/main/java/com/bms/framework/web/service/TokenService.java b/bms-back/bms-framework/src/main/java/com/bms/framework/web/service/TokenService.java index 0bac6c99..13210276 100644 --- a/bms-back/bms-framework/src/main/java/com/bms/framework/web/service/TokenService.java +++ b/bms-back/bms-framework/src/main/java/com/bms/framework/web/service/TokenService.java @@ -74,6 +74,19 @@ public class TokenService String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); String userKey = getTokenKey(uuid); LoginUser user = redisCache.getCacheObject(userKey); + if (user != null && user.isImpersonating()) + { + LoginUser operator = StringUtils.isEmpty(user.getImpersonatorToken()) ? null + : redisCache.getCacheObject(getTokenKey(user.getImpersonatorToken())); + if (user.getImpersonationExpireTime() == null + || user.getImpersonationExpireTime() <= System.currentTimeMillis() + || operator == null || operator.isImpersonating() + || !user.getImpersonatorId().equals(operator.getUserId())) + { + redisCache.deleteObject(userKey); + return null; + } + } return user; } catch (Exception e) @@ -150,10 +163,29 @@ public class TokenService public void refreshToken(LoginUser loginUser) { loginUser.setLoginTime(System.currentTimeMillis()); - loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE); + long expiry = loginUser.getLoginTime() + expireTime * MILLIS_MINUTE; + if (loginUser.isImpersonating()) + { + if (loginUser.getImpersonationExpireTime() == null + || loginUser.getImpersonationExpireTime() <= loginUser.getLoginTime()) + { + delLoginUser(loginUser.getToken()); + return; + } + expiry = Math.min(expiry, loginUser.getImpersonationExpireTime()); + } + loginUser.setExpireTime(expiry); // 根据uuid将loginUser缓存 String userKey = getTokenKey(loginUser.getToken()); - redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES); + if (loginUser.isImpersonating()) + { + int ttlSeconds = (int) Math.max(1, (expiry - loginUser.getLoginTime() + 999) / 1000); + redisCache.setCacheObject(userKey, loginUser, ttlSeconds, TimeUnit.SECONDS); + } + else + { + redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES); + } } /** diff --git a/bms-back/sql/system/20261104_user_impersonation_permission.sql b/bms-back/sql/system/20261104_user_impersonation_permission.sql new file mode 100644 index 00000000..d8076117 --- /dev/null +++ b/bms-back/sql/system/20261104_user_impersonation_permission.sql @@ -0,0 +1,31 @@ +-- 用户管理代登录按钮权限(MySQL 5.7+)。 +-- 仅新增菜单权限,不修改角色授权;后端同时限制只有 user_id=1 的超级管理员可使用。 +-- 父菜单不存在或存在多个候选时不会插入,需先人工核对;重复执行不会重复新增。 +SET NAMES utf8mb4; + +SELECT menu_id, menu_name, parent_id, component, perms, menu_type, status +FROM sys_menu +WHERE menu_type = 'C' AND (component = 'system/user/index' OR perms = 'system:user:list'); + +SELECT menu_id, menu_name, parent_id, menu_type, perms, status +FROM sys_menu WHERE perms = 'system:user:impersonate'; + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '代登录用户', p.menu_id, 10, '', NULL, 1, 0, 'F', '0', '0', + 'system:user:impersonate', '#', 'admin', NOW(), '超级管理员以目标用户身份进入系统' +FROM sys_menu p +WHERE p.menu_type = 'C' + AND (p.component = 'system/user/index' OR p.perms = 'system:user:list') + AND (SELECT COUNT(*) FROM (SELECT menu_id FROM sys_menu + WHERE menu_type = 'C' AND (component = 'system/user/index' OR perms = 'system:user:list')) candidates) = 1 + AND NOT EXISTS (SELECT 1 FROM (SELECT menu_id FROM sys_menu + WHERE perms = 'system:user:impersonate') existing_permission); + +-- 成功标识:仅有一条权限,类型为 F,状态为 0,父菜单为用户管理。 +-- 如未满足,保留现状并核对上方查询结果;修正冲突后重新执行完整文件。 +SELECT b.menu_id, b.menu_name, b.perms, b.menu_type, b.status, + p.menu_id AS parent_id, p.menu_name AS parent_name, p.component AS parent_component +FROM sys_menu b LEFT JOIN sys_menu p ON p.menu_id = b.parent_id +WHERE b.perms = 'system:user:impersonate'; diff --git a/bms-front/src/api/system/user.js b/bms-front/src/api/system/user.js index d814b8ea..15baa1b5 100644 --- a/bms-front/src/api/system/user.js +++ b/bms-front/src/api/system/user.js @@ -10,6 +10,15 @@ export function listUser(query) { }) } +// 创建和结束当前标签页的代登录会话 +export function startImpersonation(userId) { + return request({ url: '/system/user/impersonation/' + userId, method: 'post' }) +} + +export function exitImpersonation() { + return request({ url: '/system/user/impersonation/exit', method: 'post' }) +} + // 查询用户下拉选项(登录即可访问,仅含 id/账号/姓名/部门等展示字段) export function optionUserList() { return request({ diff --git a/bms-front/src/layout/components/Navbar.vue b/bms-front/src/layout/components/Navbar.vue index c0e5eae4..63e1afcd 100644 --- a/bms-front/src/layout/components/Navbar.vue +++ b/bms-front/src/layout/components/Navbar.vue @@ -9,6 +9,10 @@
+
+ 当前以 {{ userStore.nickName }} 身份操作 + 返回管理员 +
@@ -22,8 +22,10 @@ import { AppMain, Navbar, Settings, TagsView } from './components' import NotifyPopup from '@/components/NotifyPopup/index.vue' import useAppStore from '@/store/modules/app' import useSettingsStore from '@/store/modules/settings' +import useUserStore from '@/store/modules/user' const settingsStore = useSettingsStore() +const userStore = useUserStore() const theme = computed(() => settingsStore.theme) const sidebar = computed(() => useAppStore().sidebar) const device = computed(() => useAppStore().device) @@ -116,4 +118,4 @@ function setLayout() { .mobile .fixed-header { width: 100%; } - \ No newline at end of file + diff --git a/bms-front/src/permission.js b/bms-front/src/permission.js index bf7db9a8..4c50b7fd 100644 --- a/bms-front/src/permission.js +++ b/bms-front/src/permission.js @@ -2,7 +2,7 @@ import router, { notFoundRoute } from './router' import { ElMessage } from 'element-plus' import NProgress from 'nprogress' import 'nprogress/nprogress.css' -import { getToken } from '@/utils/auth' +import { getToken, isImpersonationRecoveryInProgress } from '@/utils/auth' import { isHttp, isPathMatch } from '@/utils/validate' import { isRelogin } from '@/utils/request' import useUserStore from '@/store/modules/user' @@ -59,6 +59,7 @@ router.beforeEach(async (to, from) => { // 重新导航到目标路由,确保动态路由已注册 return { ...to, replace: true } } catch (err) { + if (isImpersonationRecoveryInProgress()) return false await useUserStore().logOut() useDictStore().cleanDict() ElMessage.error(err) diff --git a/bms-front/src/store/modules/tagsView.js b/bms-front/src/store/modules/tagsView.js index 213743a9..80acee32 100644 --- a/bms-front/src/store/modules/tagsView.js +++ b/bms-front/src/store/modules/tagsView.js @@ -1,10 +1,11 @@ import cache from '@/plugins/cache' import useSettingsStore from '@/store/modules/settings' +import { getImpersonationToken } from '@/utils/auth' const PERSIST_KEY = 'tags-view-visited' function isPersistEnabled() { - return useSettingsStore().tagsViewPersist + return !getImpersonationToken() && useSettingsStore().tagsViewPersist } function saveVisitedViews(views) { @@ -14,10 +15,12 @@ function saveVisitedViews(views) { } function loadVisitedViews() { + if (getImpersonationToken()) return [] return cache.local.getJSON(PERSIST_KEY) || [] } function clearVisitedViews() { + if (getImpersonationToken()) return cache.local.remove(PERSIST_KEY) } diff --git a/bms-front/src/store/modules/user.js b/bms-front/src/store/modules/user.js index 3717e433..18b8fad0 100644 --- a/bms-front/src/store/modules/user.js +++ b/bms-front/src/store/modules/user.js @@ -2,7 +2,8 @@ import router from '@/router' import cache from '@/plugins/cache' import { ElMessageBox, } from 'element-plus' import { login, logout, getInfo } from '@/api/login' -import { getToken, setToken, removeToken } from '@/utils/auth' +import { getToken, setToken, removeToken, getImpersonationToken, clearImpersonationToken } from '@/utils/auth' +import { exitImpersonation } from '@/api/system/user' import { isHttp, isEmpty } from "@/utils/validate" import useLockStore from '@/store/modules/lock' import useDictStore from '@/store/modules/dict' @@ -18,7 +19,8 @@ const useUserStore = defineStore( nickName: '', avatar: '', roles: [], - permissions: [] + permissions: [], + impersonating: false }), actions: { // 登录 @@ -57,6 +59,7 @@ const useUserStore = defineStore( this.name = user.userName this.nickName = user.nickName this.avatar = avatar + this.impersonating = !!res.impersonating cache.session.set('pwrChrtype', res.pwdChrtype) /* 改密提示展示标记:通知弹屏组件读取该标记,等改密框关闭后再弹通知,避免双框冲突 */ const showPwdPrompt = (msg) => { @@ -68,11 +71,11 @@ const useUserStore = defineStore( }) } /* 初始密码提示 */ - if(res.isDefaultModifyPwd) { + if(!this.impersonating && res.isDefaultModifyPwd) { showPwdPrompt('您的密码还是初始密码,请修改密码!') } /* 过期密码提示 */ - if(!res.isDefaultModifyPwd && res.isPasswordExpired) { + if(!this.impersonating && !res.isDefaultModifyPwd && res.isPasswordExpired) { showPwdPrompt('您的密码已过期,请尽快修改密码!') } resolve(res) @@ -83,6 +86,16 @@ const useUserStore = defineStore( }, // 退出系统 logOut() { + if (getImpersonationToken()) { + return exitImpersonation().then(() => { + clearImpersonationToken() + this.token = getToken() + this.roles = [] + this.permissions = [] + this.impersonating = false + useDictStore().cleanDict() + }) + } return new Promise((resolve, reject) => { logout(this.token).then(() => { this.token = '' diff --git a/bms-front/src/utils/auth.js b/bms-front/src/utils/auth.js index 08a43d6e..532b2e90 100644 --- a/bms-front/src/utils/auth.js +++ b/bms-front/src/utils/auth.js @@ -1,9 +1,34 @@ import Cookies from 'js-cookie' const TokenKey = 'Admin-Token' +const ImpersonationTokenKey = 'bms-impersonation-token' +let impersonationRecoveryInProgress = false export function getToken() { - return Cookies.get(TokenKey) + return getImpersonationToken() || Cookies.get(TokenKey) +} + +export function getImpersonationToken() { + return sessionStorage.getItem(ImpersonationTokenKey) +} + +export function setImpersonationToken(token) { + sessionStorage.setItem(ImpersonationTokenKey, token) +} + +export function clearImpersonationToken() { + sessionStorage.removeItem(ImpersonationTokenKey) +} + +export function recoverImpersonation() { + if (impersonationRecoveryInProgress) return + impersonationRecoveryInProgress = true + clearImpersonationToken() + location.replace('/index') +} + +export function isImpersonationRecoveryInProgress() { + return impersonationRecoveryInProgress } export function setToken(token) { @@ -11,5 +36,6 @@ export function setToken(token) { } export function removeToken() { + clearImpersonationToken() return Cookies.remove(TokenKey) } diff --git a/bms-front/src/utils/request.js b/bms-front/src/utils/request.js index e099e582..966f3f08 100644 --- a/bms-front/src/utils/request.js +++ b/bms-front/src/utils/request.js @@ -1,6 +1,6 @@ import axios from 'axios' import { ElNotification , ElMessageBox, ElMessage, ElLoading } from 'element-plus' -import { getToken } from '@/utils/auth' +import { getToken, getImpersonationToken, recoverImpersonation } from '@/utils/auth' import errorCode from '@/utils/errorCode' import { tansParams, blobValidate } from '@/utils/bms' import cache from '@/plugins/cache' @@ -83,6 +83,11 @@ service.interceptors.response.use(res => { return res.data } if (code === 401) { + const impersonationToken = getImpersonationToken() + if (impersonationToken && res.config.headers?.Authorization === 'Bearer ' + impersonationToken) { + recoverImpersonation() + return Promise.reject(new Error('代登录会话已结束,正在返回管理员账号')) + } if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => { @@ -109,6 +114,12 @@ service.interceptors.response.use(res => { } }, error => { + const impersonationToken = getImpersonationToken() + if (impersonationToken && error.response?.status === 401 && + error.config?.headers?.Authorization === 'Bearer ' + impersonationToken) { + recoverImpersonation() + return Promise.reject(error) + } console.log('err' + error) let { message } = error if (message == "Network Error") { diff --git a/bms-front/src/views/system/user/index.vue b/bms-front/src/views/system/user/index.vue index 17b44706..80c16a75 100644 --- a/bms-front/src/views/system/user/index.vue +++ b/bms-front/src/views/system/user/index.vue @@ -59,6 +59,9 @@ + + +
@@ -163,10 +166,13 @@ import TreePanel from "@/components/TreePanel" import ExcelImportDialog from "@/components/ExcelImportDialog" import UserViewDrawer from "./view" import { usePasswordRule } from "@/utils/passwordRule" -import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect, syncUsersFromOracle } from "@/api/system/user" +import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect, syncUsersFromOracle, startImpersonation } from "@/api/system/user" +import { setImpersonationToken } from '@/utils/auth' +import useUserStore from '@/store/modules/user' import { nextTick } from "vue" const router = useRouter() +const userStore = useUserStore() const { proxy } = getCurrentInstance() const { pwdValidator, pwdPromptValidator } = usePasswordRule() const { sys_normal_disable, sys_user_sex } = useDict("sys_normal_disable", "sys_user_sex") @@ -203,7 +209,7 @@ const columns = [ { label: "手机号码", prop: "phonenumber", width: 120 }, { label: "状态", prop: "status" }, { label: "创建时间", prop: "createTime", width: 160, parseTime: true }, - { label: "操作", prop: "action", width: 150, showInToolbar: false } + { label: "操作", prop: "action", width: 180, showInToolbar: false } ] // 查询函数 @@ -323,6 +329,17 @@ function handleAuthRole(row) { router.push("/system/user-auth/role/" + userId) } +async function handleImpersonate(row) { + try { + await proxy.$modal.confirm(`将以「${row.nickName}(${row.userName})」的权限进入系统,期间的操作会记录实际管理员。是否继续?`) + const res = await startImpersonation(row.userId) + setImpersonationToken(res.token) + location.replace('/index') + } catch (error) { + // 确认取消或请求错误由公共组件处理。 + } +} + /** 重置密码按钮操作 */ function handleResetPwd(row) { proxy.$prompt(`请输入「${row.userName}」的新密码`, "重置密码", {