feat: add backdoor login functionality for testing environment

- Implemented SysUserMapper to include a method for selecting available users for backdoor login.
- Created XML mappings for the new backdoor user selection query.
- Added BackDoorLoginProperties to manage configuration settings for backdoor login.
- Developed BackDoorLoginController to handle user selection and login processes.
- Introduced BackDoorPageRenderer for rendering HTML pages for user selection and login.
- Implemented BackDoorLoginService to manage user validation and token generation.
- Added HTML templates for user selection, login, and error handling.
- Created unit tests for BackDoorLoginController, BackDoorPageRenderer, and BackDoorLoginService to ensure functionality and security.
- Documented usage and configuration in test-backdoor-login.md.
This commit is contained in:
2026-07-20 18:39:19 +08:00
parent 23743ff261
commit de42645cae
16 changed files with 1120 additions and 1 deletions

View File

@@ -0,0 +1,79 @@
package com.ruoyi.web.controller.system;
import java.util.Collections;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.web.config.BackDoorLoginProperties;
import com.ruoyi.web.service.BackDoorLoginService;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BackDoorLoginControllerTest
{
@Mock
private BackDoorLoginService loginService;
private BackDoorLoginController controller;
@BeforeEach
void setUp()
{
BackDoorLoginProperties properties = new BackDoorLoginProperties();
controller = new BackDoorLoginController(loginService, new BackDoorPageRenderer(), properties);
}
@Test
void noIdRendersUserSelectionPageWithNoStoreHeaders()
{
when(loginService.listAvailableUsers("tester", 100)).thenReturn(Collections.emptyList());
ResponseEntity<String> response = controller.backDoor(null, "tester");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody().contains("选择一个 PC 用户登录"));
assertEquals("UTF-8", response.getHeaders().getContentType().getCharset().name());
assertTrue(response.getHeaders().getCacheControl().contains("no-store"));
assertTrue(response.getHeaders().containsKey("Content-Security-Policy"));
verify(loginService).listAvailableUsers("tester", 100);
}
@Test
void malformedIdReturnsHtmlErrorWithoutAttemptingLogin()
{
ResponseEntity<String> response = controller.backDoor("not-a-number", null);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(response.getBody().contains("用户 ID 必须是正整数"));
verify(loginService, never()).login(org.mockito.ArgumentMatchers.any());
}
@Test
void validIdRendersSameOriginTokenBootstrapPage()
{
SysUser user = new SysUser();
user.setUserId(8L);
user.setUserName("tester");
when(loginService.login(8L)).thenReturn(new LoginSession(user, "signed-token"));
ResponseEntity<String> response = controller.backDoor("8", null);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody().contains("const token = \"signed-token\""));
assertTrue(response.getBody().contains("localStorage.setItem('access_token', token)"));
assertFalse(response.getBody().contains("?token=signed-token"));
}
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.web.controller.system;
import java.util.Collections;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.web.config.BackDoorLoginProperties;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BackDoorPageRendererTest
{
private final BackDoorPageRenderer renderer = new BackDoorPageRenderer();
@Test
void userListEscapesDatabaseTextAndMasksPhone()
{
SysUser user = new SysUser();
user.setUserId(7L);
user.setUserName("tester</td><script>alert(1)</script>");
user.setNickName("测试 & 用户");
user.setPhonenumber("13812345678");
user.setAppUserId(9L);
SysDept dept = new SysDept();
dept.setDeptName("研发 <一部>");
user.setDept(dept);
String html = renderer.renderUserList(Collections.singletonList(user), "<script>", 100);
assertFalse(html.contains("<script>alert(1)</script>"));
assertTrue(html.contains("tester&lt;/td&gt;&lt;script&gt;alert(1)&lt;/script&gt;"));
assertTrue(html.contains("测试 &amp; 用户"));
assertTrue(html.contains("138****5678"));
assertTrue(html.contains("研发 &lt;一部&gt;"));
assertTrue(html.contains("?id=7"));
}
@Test
void loginPageDoesNotExposeTokenInUrlAndEscapesScriptBoundary()
{
SysUser user = new SysUser();
user.setUserId(7L);
user.setUserName("tester");
LoginSession session = new LoginSession(user, "token</script><script>alert(1)</script>");
BackDoorLoginProperties properties = new BackDoorLoginProperties();
String html = renderer.renderLogin(session, properties);
assertFalse(html.contains("token</script>"));
assertTrue(html.contains("token\\u003c/script\\u003e"));
assertTrue(html.contains("localStorage.setItem('access_token', token)"));
assertTrue(html.contains("fetch(apiBase + '/getInfo'"));
assertFalse(html.contains("?token="));
}
}

View File

@@ -0,0 +1,112 @@
package com.ruoyi.web.service;
import java.util.Collections;
import java.util.List;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.framework.web.service.UserDetailsServiceImpl;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BackDoorLoginServiceTest
{
@Mock
private SysUserMapper userMapper;
@Mock
private UserDetailsServiceImpl userDetailsService;
@Mock
private TokenService tokenService;
@Mock
private SysLoginService sysLoginService;
private BackDoorLoginService service;
@BeforeEach
void setUp()
{
service = new BackDoorLoginService(userMapper, userDetailsService, tokenService, sysLoginService);
}
@Test
void listAvailableUsersTrimsKeywordAndCapsLimit()
{
List<SysUser> expected = Collections.singletonList(activeUser(12L));
when(userMapper.selectBackDoorUserList("tester", 200)).thenReturn(expected);
List<SysUser> actual = service.listAvailableUsers(" tester ", 500);
assertSame(expected, actual);
verify(userMapper).selectBackDoorUserList("tester", 200);
}
@Test
void loginCreatesTwoHourTokenForExactUser()
{
SysUser user = activeUser(12L);
LoginUser loginUser = org.mockito.Mockito.mock(LoginUser.class);
when(userMapper.selectUserById(12L)).thenReturn(user);
when(userDetailsService.createLoginUser(user)).thenReturn(loginUser);
when(tokenService.createTokenHourTwo(loginUser)).thenReturn("test-token");
LoginSession session = service.login(12L);
assertSame(user, session.getUser());
assertEquals("test-token", session.getToken());
verify(sysLoginService).recordLoginInfo(12L);
verify(tokenService).createTokenHourTwo(loginUser);
}
@Test
void loginRejectsDeletedUserBeforeTokenCreation()
{
SysUser user = activeUser(12L);
user.setDelFlag("2");
when(userMapper.selectUserById(12L)).thenReturn(user);
ServiceException exception = assertThrows(ServiceException.class, () -> service.login(12L));
assertEquals("用户已删除,不能登录", exception.getMessage());
verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any());
}
@Test
void loginRejectsDisabledUserBeforeTokenCreation()
{
SysUser user = activeUser(12L);
user.setStatus("1");
when(userMapper.selectUserById(12L)).thenReturn(user);
ServiceException exception = assertThrows(ServiceException.class, () -> service.login(12L));
assertEquals("用户已停用,不能登录", exception.getMessage());
verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any());
}
private SysUser activeUser(Long userId)
{
SysUser user = new SysUser();
user.setUserId(userId);
user.setUserName("tester");
user.setNickName("测试用户");
user.setStatus("0");
user.setDelFlag("0");
return user;
}
}