✨ 新增管理员代登录功能

This commit is contained in:
caojia
2026-11-04 15:20:05 +08:00
parent 93da04fe5e
commit 5cccbf510f
17 changed files with 540 additions and 18 deletions
@@ -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();
}
}
@@ -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()));
@@ -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<Object> 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<Integer> 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()));
}
}
@@ -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()
{
}
@@ -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());
// 保存数据库
@@ -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);
}
}
}
}
@@ -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);
}
}
/**
@@ -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';