✨ 新增管理员代登录功能
This commit is contained in:
+80
@@ -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()));
|
||||
|
||||
+209
@@ -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());
|
||||
// 保存数据库
|
||||
|
||||
+48
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -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';
|
||||
@@ -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({
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
</template>
|
||||
|
||||
<div class="right-menu">
|
||||
<div v-if="userStore.impersonating" class="impersonation-banner">
|
||||
<span>当前以 {{ userStore.nickName }} 身份操作</span>
|
||||
<el-button size="small" type="warning" @click="exitActingUser">返回管理员</el-button>
|
||||
</div>
|
||||
<template v-if="appStore.device !== 'mobile'">
|
||||
<header-search id="header-search" class="right-menu-item" />
|
||||
|
||||
@@ -50,7 +54,7 @@
|
||||
<el-dropdown-item command="setLayout" v-if="settingsStore.showSettings">
|
||||
<span>布局设置</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="lockScreen">
|
||||
<el-dropdown-item v-if="!userStore.impersonating" command="lockScreen">
|
||||
<span>锁定屏幕</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
@@ -111,7 +115,7 @@ function handleCommand(command) {
|
||||
}
|
||||
|
||||
function logout() {
|
||||
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
|
||||
ElMessageBox.confirm(userStore.impersonating ? '结束代登录并返回管理员账号吗?' : '确定注销并退出系统吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
@@ -122,6 +126,10 @@ function logout() {
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
function exitActingUser() {
|
||||
userStore.logOut().then(() => location.replace('/index'))
|
||||
}
|
||||
|
||||
const emits = defineEmits(['setLayout'])
|
||||
function setLayout() {
|
||||
emits('setLayout')
|
||||
@@ -233,6 +241,15 @@ async function toggleTheme(event) {
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
.impersonation-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #b46a00;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<settings ref="settingRef" />
|
||||
</div>
|
||||
<!-- 全局消息通知:登录后未读弹屏 + 头像菜单“我的通知”入口 -->
|
||||
<notify-popup />
|
||||
<notify-popup v-if="!userStore.impersonating" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<el-tooltip content="分配角色" placement="top" v-if="row.userId !== 1">
|
||||
<el-button link type="primary" icon="CircleCheck" @click="handleAuthRole(row)" v-hasPermi="['system:user:edit']"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="以此用户身份进入" placement="top" v-if="String(userStore.id) === '1' && String(row.userId) !== '1' && row.status === '0'">
|
||||
<el-button link type="primary" icon="User" @click="handleImpersonate(row)" v-hasPermi="['system:user:impersonate']"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</basic-table>
|
||||
</div>
|
||||
@@ -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}」的新密码`, "重置密码", {
|
||||
|
||||
Reference in New Issue
Block a user