diff --git a/docs/test-backdoor-login.md b/docs/test-backdoor-login.md new file mode 100644 index 0000000..f196ae0 --- /dev/null +++ b/docs/test-backdoor-login.md @@ -0,0 +1,57 @@ +# 测试环境 PC 用户后门登录 + +测试环境无法访问外部统一门户时,可以通过用户选择页直接建立本系统登录态: + +```text +http://47.111.103.66/shihezi/backDoor +``` + +页面只显示 `sys_user` 中未删除、未停用的用户,支持按用户 ID、账号、姓名或手机号搜索。手机号会脱敏,身份证号不会展示。选择用户后,后端生成与 PC 单点登录相同类型的两小时 token,页面将 token 写入同源的 `access_token` 和 `refresh_token`,再根据 `/getInfo` 返回的用户类型进入求职门户或管理端。 + +已知用户 ID 时,也可以直接访问: + +```text +http://47.111.103.66/shihezi/backDoor?id=123 +``` + +## 启用边界 + +- `application.yml` 中默认 `backdoor.login.enabled: false`。 +- 只有 `application-test.yml` 将它覆盖为 `true`。 +- Controller、登录服务和页面渲染器都使用同一个条件配置;生产环境未开启时不会注册 `/backDoor` 路由。 +- 每次后门登录都会记录用户 ID、账号和正常的登录信息,便于审计。 + +## Nginx 路由 + +当前 `/shihezi/` 是前端 SPA 路径。必须在 SPA 的通用 `location` 之前,将后门路径精确转发到后端;否则浏览器会把 `/shihezi/backDoor` 当成前端路由并跳转到普通登录页。 + +示例(后端监听地址按测试服务器实际配置调整): + +```nginx +location = /shihezi/backDoor { + proxy_pass http://127.0.0.1:9091/backDoor; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +查询参数会保留,因此用户列表搜索和 `?id=123` 直接登录都使用同一条规则。配置完成后先执行 `nginx -t`,再按项目的测试环境发布流程加载配置。本仓库不会自动部署或重载 Nginx。 + +## 本地访问 + +以 `test` profile 启动后,可以直接访问后端地址: + +```text +http://127.0.0.1:9091/backDoor +``` + +若本地前端路径与测试环境不同,可覆盖: + +```yaml +backdoor: + login: + portal-base-path: /shihezi + api-base-path: /api/shihezi +``` diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 525b479..915b17f 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -110,6 +110,12 @@ gson 2.9.1 + + + org.springframework.boot + spring-boot-starter-test + test + @@ -142,4 +148,4 @@ ${project.artifactId} - \ No newline at end of file + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java new file mode 100644 index 0000000..fb1723a --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java @@ -0,0 +1,78 @@ +package com.ruoyi.web.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 测试环境 PC 用户后门登录配置。 + */ +@Component +@ConfigurationProperties(prefix = "backdoor.login") +public class BackDoorLoginProperties +{ + private static final int DEFAULT_MAX_USERS = 100; + private static final int MAX_ALLOWED_USERS = 200; + + private boolean enabled; + private int maxUsers = DEFAULT_MAX_USERS; + private String portalBasePath = "/shihezi"; + private String apiBasePath = "/api/shihezi"; + + public boolean isEnabled() + { + return enabled; + } + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + public int getMaxUsers() + { + return Math.max(1, Math.min(maxUsers, MAX_ALLOWED_USERS)); + } + + public void setMaxUsers(int maxUsers) + { + this.maxUsers = maxUsers; + } + + public String getPortalBasePath() + { + return normalizeBasePath(portalBasePath, "/shihezi"); + } + + public void setPortalBasePath(String portalBasePath) + { + this.portalBasePath = portalBasePath; + } + + public String getApiBasePath() + { + return normalizeBasePath(apiBasePath, "/api/shihezi"); + } + + public void setApiBasePath(String apiBasePath) + { + this.apiBasePath = apiBasePath; + } + + private String normalizeBasePath(String value, String defaultValue) + { + if (value == null || value.trim().isEmpty()) + { + return defaultValue; + } + String path = value.trim(); + if (!path.startsWith("/")) + { + path = "/" + path; + } + while (path.length() > 1 && path.endsWith("/")) + { + path = path.substring(0, path.length() - 1); + } + return path; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java new file mode 100644 index 0000000..7d776ce --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java @@ -0,0 +1,119 @@ +package com.ruoyi.web.controller.system; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.web.config.BackDoorLoginProperties; +import com.ruoyi.web.service.BackDoorLoginService; +import com.ruoyi.web.service.BackDoorLoginService.LoginSession; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 测试环境 PC 用户选择和免单点登录入口。 + */ +@Anonymous +@RestController +@ConditionalOnProperty(prefix = "backdoor.login", name = "enabled", havingValue = "true") +public class BackDoorLoginController +{ + private static final Logger log = LoggerFactory.getLogger(BackDoorLoginController.class); + private static final MediaType HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); + + private final BackDoorLoginService backDoorLoginService; + private final BackDoorPageRenderer pageRenderer; + private final BackDoorLoginProperties properties; + + public BackDoorLoginController(BackDoorLoginService backDoorLoginService, + BackDoorPageRenderer pageRenderer, + BackDoorLoginProperties properties) + { + this.backDoorLoginService = backDoorLoginService; + this.pageRenderer = pageRenderer; + this.properties = properties; + } + + @GetMapping(value = "/backDoor", produces = "text/html;charset=UTF-8") + public ResponseEntity backDoor( + @RequestParam(value = "id", required = false) String id, + @RequestParam(value = "keyword", required = false) String keyword) + { + if (StringUtils.isBlank(id)) + { + return renderUserList(keyword); + } + + Long userId; + try + { + userId = Long.valueOf(id.trim()); + if (userId <= 0) + { + throw new NumberFormatException("non-positive user id"); + } + } + catch (NumberFormatException e) + { + return html(HttpStatus.BAD_REQUEST, pageRenderer.renderError("用户 ID 必须是正整数")); + } + + try + { + LoginSession session = backDoorLoginService.login(userId); + return html(HttpStatus.OK, pageRenderer.renderLogin(session, properties)); + } + catch (ServiceException e) + { + return html(HttpStatus.BAD_REQUEST, pageRenderer.renderError(e.getMessage())); + } + catch (Exception e) + { + log.error("测试环境后门登录失败,userId={}", userId, e); + return html(HttpStatus.INTERNAL_SERVER_ERROR, pageRenderer.renderError("登录失败,请稍后重试")); + } + } + + private ResponseEntity renderUserList(String keyword) + { + try + { + List users = backDoorLoginService.listAvailableUsers(keyword, properties.getMaxUsers()); + return html(HttpStatus.OK, + pageRenderer.renderUserList(users, StringUtils.trimToEmpty(keyword), properties.getMaxUsers())); + } + catch (Exception e) + { + log.error("加载测试环境后门用户列表失败", e); + return html(HttpStatus.INTERNAL_SERVER_ERROR, pageRenderer.renderError("加载用户列表失败,请稍后重试")); + } + } + + private ResponseEntity html(HttpStatus status, String body) + { + return ResponseEntity.status(status) + .contentType(HTML_UTF8) + .cacheControl(CacheControl.noStore().mustRevalidate()) + .header(HttpHeaders.PRAGMA, "no-cache") + .header("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " + + "connect-src 'self'; img-src data:; form-action 'self'; base-uri 'none'; " + + "frame-ancestors 'none'") + .header("X-Content-Type-Options", "nosniff") + .header("X-Frame-Options", "DENY") + .header("Referrer-Policy", "no-referrer") + .body(body); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java new file mode 100644 index 0000000..b7de731 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java @@ -0,0 +1,146 @@ +package com.ruoyi.web.controller.system; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import com.alibaba.fastjson2.JSON; +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.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.web.util.HtmlUtils; + +/** + * 渲染后门用户选择页和登录跳转页。 + */ +@Component +@ConditionalOnProperty(prefix = "backdoor.login", name = "enabled", havingValue = "true") +public class BackDoorPageRenderer +{ + private static final long TOKEN_LIFETIME_MILLIS = TimeUnit.HOURS.toMillis(2); + + private final String userListTemplate; + private final String loginTemplate; + private final String errorTemplate; + + public BackDoorPageRenderer() + { + this.userListTemplate = loadTemplate("backdoor/user-list.html"); + this.loginTemplate = loadTemplate("backdoor/login.html"); + this.errorTemplate = loadTemplate("backdoor/error.html"); + } + + public String renderUserList(List users, String keyword, int maxUsers) + { + List safeUsers = users == null ? Collections.emptyList() : users; + return userListTemplate + .replace("@@ROWS@@", renderRows(safeUsers)) + .replace("@@KEYWORD@@", html(keyword)) + .replace("@@COUNT@@", String.valueOf(safeUsers.size())) + .replace("@@MAX_USERS@@", String.valueOf(maxUsers)); + } + + public String renderLogin(LoginSession session, BackDoorLoginProperties properties) + { + SysUser user = session.getUser(); + String displayName = firstNotBlank(user.getNickName(), user.getUserName(), String.valueOf(user.getUserId())); + long expireAt = System.currentTimeMillis() + TOKEN_LIFETIME_MILLIS; + + return loginTemplate + .replace("@@USER_NAME@@", html(displayName)) + .replace("@@TOKEN_JSON@@", javascriptString(session.getToken())) + .replace("@@EXPIRE_AT@@", String.valueOf(expireAt)) + .replace("@@API_BASE_PATH_JSON@@", javascriptString(properties.getApiBasePath())) + .replace("@@PORTAL_BASE_PATH_JSON@@", javascriptString(properties.getPortalBasePath())); + } + + public String renderError(String message) + { + return errorTemplate.replace("@@MESSAGE@@", html(message)); + } + + private String renderRows(List users) + { + if (users.isEmpty()) + { + return "没有找到可登录的 PC 用户"; + } + + StringBuilder rows = new StringBuilder(users.size() * 320); + for (SysUser user : users) + { + SysDept dept = user.getDept(); + String deptName = dept == null ? "-" : StringUtils.defaultIfBlank(dept.getDeptName(), "-"); + String userKind = user.getAppUserId() == null ? "系统用户" : "门户关联用户"; + rows.append("") + .append("").append(user.getUserId()).append("") + .append("").append(html(user.getUserName())).append("") + .append("").append(html(StringUtils.defaultIfBlank(user.getNickName(), "-"))).append("") + .append("").append(html(maskPhone(user.getPhonenumber()))).append("") + .append("").append(userKind).append("
") + .append(html(deptName)).append("") + .append("选择并登录") + .append(""); + } + return rows.toString(); + } + + private String maskPhone(String phone) + { + String value = StringUtils.trimToEmpty(phone); + if (value.length() >= 7) + { + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + return StringUtils.defaultIfBlank(value, "-"); + } + + private String firstNotBlank(String... values) + { + for (String value : values) + { + if (StringUtils.isNotBlank(value)) + { + return value; + } + } + return "未知用户"; + } + + private String html(String value) + { + return HtmlUtils.htmlEscape(StringUtils.defaultString(value)); + } + + private String javascriptString(String value) + { + return JSON.toJSONString(StringUtils.defaultString(value)) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029"); + } + + private String loadTemplate(String path) + { + ClassPathResource resource = new ClassPathResource(path); + try + { + return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + } + catch (IOException e) + { + throw new IllegalStateException("无法加载后门登录页面模板:" + path, e); + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java b/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java new file mode 100644 index 0000000..7ecc71b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java @@ -0,0 +1,116 @@ +package com.ruoyi.web.service; + +import java.util.List; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.enums.UserStatus; +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 org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * 仅在测试环境显式开启的 PC 用户后门登录服务。 + */ +@Service +@ConditionalOnProperty(prefix = "backdoor.login", name = "enabled", havingValue = "true") +public class BackDoorLoginService +{ + private static final Logger log = LoggerFactory.getLogger(BackDoorLoginService.class); + private static final int MAX_KEYWORD_LENGTH = 50; + private static final int MAX_USER_LIMIT = 200; + + private final SysUserMapper userMapper; + private final UserDetailsServiceImpl userDetailsService; + private final TokenService tokenService; + private final SysLoginService loginService; + + public BackDoorLoginService(SysUserMapper userMapper, + UserDetailsServiceImpl userDetailsService, + TokenService tokenService, + SysLoginService loginService) + { + this.userMapper = userMapper; + this.userDetailsService = userDetailsService; + this.tokenService = tokenService; + this.loginService = loginService; + } + + public List listAvailableUsers(String keyword, int limit) + { + String normalizedKeyword = StringUtils.trimToNull(keyword); + if (normalizedKeyword != null && normalizedKeyword.length() > MAX_KEYWORD_LENGTH) + { + normalizedKeyword = normalizedKeyword.substring(0, MAX_KEYWORD_LENGTH); + } + int safeLimit = Math.max(1, Math.min(limit, MAX_USER_LIMIT)); + return userMapper.selectBackDoorUserList(normalizedKeyword, safeLimit); + } + + public LoginSession login(Long userId) + { + if (userId == null || userId <= 0) + { + throw new ServiceException("用户 ID 必须是正整数"); + } + + SysUser user = userMapper.selectUserById(userId); + validateUser(user); + + LoginUser loginUser = (LoginUser) userDetailsService.createLoginUser(user); + loginService.recordLoginInfo(user.getUserId()); + String token = tokenService.createTokenHourTwo(loginUser); + + log.warn("测试环境后门登录:userId={}, userName={}", user.getUserId(), user.getUserName()); + return new LoginSession(user, token); + } + + private void validateUser(SysUser user) + { + if (user == null) + { + throw new ServiceException("用户不存在"); + } + if (!UserStatus.OK.getCode().equals(user.getDelFlag())) + { + throw new ServiceException("用户已删除,不能登录"); + } + if (!UserStatus.OK.getCode().equals(user.getStatus())) + { + throw new ServiceException("用户已停用,不能登录"); + } + if (StringUtils.isBlank(user.getUserName())) + { + throw new ServiceException("用户账号为空,不能登录"); + } + } + + public static class LoginSession + { + private final SysUser user; + private final String token; + + public LoginSession(SysUser user, String token) + { + this.user = user; + this.token = token; + } + + public SysUser getUser() + { + return user; + } + + public String getToken() + { + return token; + } + } +} diff --git a/ruoyi-admin/src/main/resources/application-test.yml b/ruoyi-admin/src/main/resources/application-test.yml index 361c331..c85dcdf 100644 --- a/ruoyi-admin/src/main/resources/application-test.yml +++ b/ruoyi-admin/src/main/resources/application-test.yml @@ -144,3 +144,11 @@ lc_cms_auth: getUserInfoUrl: http://218.31.252.15:9081/prod-api/system/app/authorize/user/info resrtPwdUrl: http://218.31.252.15:9081/prod-api/ps/user/resetPwd/zkr changeStatusUrl: http://218.31.252.15:9081/prod-api/ps/user/change/status/zkr + +# 测试环境无法连接统一门户时,允许从 PC 用户列表直接建立本系统登录态。 +backdoor: + login: + enabled: true + max-users: 100 + portal-base-path: /shihezi + api-base-path: /api/shihezi diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 5c0a451..92aa158 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -60,6 +60,14 @@ token: # 令牌有效期(默认30分钟) expireTime: 30 +# 测试用户后门登录。默认关闭,只允许在明确的测试 profile 中覆盖开启。 +backdoor: + login: + enabled: false + max-users: 100 + portal-base-path: /shihezi + api-base-path: /api/shihezi + # PageHelper分页插件 pagehelper: helperDialect: oracle diff --git a/ruoyi-admin/src/main/resources/backdoor/error.html b/ruoyi-admin/src/main/resources/backdoor/error.html new file mode 100644 index 0000000..304d65e --- /dev/null +++ b/ruoyi-admin/src/main/resources/backdoor/error.html @@ -0,0 +1,36 @@ + + + + + + + + 无法登录 + + + +
+
!
+

无法完成登录

+

@@MESSAGE@@

+ 返回用户列表 +
+ + diff --git a/ruoyi-admin/src/main/resources/backdoor/login.html b/ruoyi-admin/src/main/resources/backdoor/login.html new file mode 100644 index 0000000..b2b0733 --- /dev/null +++ b/ruoyi-admin/src/main/resources/backdoor/login.html @@ -0,0 +1,107 @@ + + + + + + + + 正在登录 + + + +
+ +

正在进入系统

+

正在以 @@USER_NAME@@ 的身份建立登录状态…

+

登录状态已建立,但自动跳转失败,请手动选择入口。

+ +
+ + + diff --git a/ruoyi-admin/src/main/resources/backdoor/user-list.html b/ruoyi-admin/src/main/resources/backdoor/user-list.html new file mode 100644 index 0000000..94216c4 --- /dev/null +++ b/ruoyi-admin/src/main/resources/backdoor/user-list.html @@ -0,0 +1,136 @@ + + + + + + + + 测试用户登录 + + + +
+
+

选择一个 PC 用户登录

+

点击用户后将跳过外部单点登录,并以该用户身份进入石河子智慧就业服务系统。

+
仅限测试环境使用。登录行为会写入审计记录,请勿在生产环境开启此入口。
+
+
+
+ + +
+
当前 @@COUNT@@ 条,最多显示 @@MAX_USERS@@ 条
+
+
+ + + + + + + + + + + + @@ROWS@@ +
用户 ID账号姓名手机号用户来源 / 部门
+
+
如列表中没有目标账号,请使用上方搜索框;停用和已删除账号不会显示。
+
+ + diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java new file mode 100644 index 0000000..27f41c5 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java @@ -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 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 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 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")); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java new file mode 100644 index 0000000..aebc887 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java @@ -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"); + user.setNickName("测试 & 用户"); + user.setPhonenumber("13812345678"); + user.setAppUserId(9L); + SysDept dept = new SysDept(); + dept.setDeptName("研发 <一部>"); + user.setDept(dept); + + String html = renderer.renderUserList(Collections.singletonList(user), "")); + assertTrue(html.contains("tester</td><script>alert(1)</script>")); + assertTrue(html.contains("测试 & 用户")); + assertTrue(html.contains("138****5678")); + assertTrue(html.contains("研发 <一部>")); + assertTrue(html.contains("?id=7")); + } + + @Test + void loginPageDoesNotExposeTokenInUrlAndEscapesScriptBoundary() + { + SysUser user = new SysUser(); + user.setUserId(7L); + user.setUserName("tester"); + LoginSession session = new LoginSession(user, "token"); + BackDoorLoginProperties properties = new BackDoorLoginProperties(); + + String html = renderer.renderLogin(session, properties); + + assertFalse(html.contains("token")); + 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=")); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java new file mode 100644 index 0000000..1e709a0 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java @@ -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 expected = Collections.singletonList(activeUser(12L)); + when(userMapper.selectBackDoorUserList("tester", 200)).thenReturn(expected); + + List 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; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java index efbb47c..f9cce70 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java @@ -14,6 +14,15 @@ import com.ruoyi.common.core.domain.entity.SysUser; */ public interface SysUserMapper { + /** + * 查询测试环境后门登录页可选择的正常 PC 用户。 + * + * @param keyword 用户 ID、账号、姓名或手机号关键字 + * @param limit 最大返回数量 + * @return 可登录用户 + */ + List selectBackDoorUserList(@Param("keyword") String keyword, @Param("limit") int limit); + /** * 根据条件分页查询用户列表 * diff --git a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index e773a40..8604601 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -49,6 +49,50 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + + + + + + + select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark,