From 00e27152fe8a62df1494ed10c929847bd4bdf3cf Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 10:20:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E9=94=AE=E7=99=BB?= =?UTF-8?q?=E5=BD=95=EF=BC=9A=E5=85=B1=E4=BA=AB=E5=AF=86=E9=92=A5=E8=87=AA?= =?UTF-8?q?=E7=AD=BE=E7=A5=A8=E6=8D=AE=E6=8D=A2=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PanelLoginTicket:v1.<时间戳>. 形状,只验签与时间窗, 不发一次性状态。机器人用同一把密钥本地签发,因此不需要机器人到主站的网络调用, 一端临时不可达也不影响生成链接。窗口内可重放(用户明确接受),过期即失效。 - PersonalController 新增 GET /login?t= 换 HttpSession(先作废旧会话防固定攻击)、 /login/logout 退出、/personal/denied 提示页。 - PersonalInterceptor 放行「有效会话或 AuthCode=alone」,拒绝时回 401 供前端区分; InterceptorConfiguration 排除登录端点与 /personal/。 - server.servlet.session.cookie.path 固定为 /:nginx 会把 /user 改写成 /personal/user, 沿用容器推导的 /personal 会让浏览器判定路径不匹配而丢会话。 - 密钥经 PERSONAL_LOGIN_SECRET 环境变量注入,为空时一律拒绝而非放行。 - 新增 PanelLoginTicketTest(7 项)与登录/退出/会话相关用例,共 423 项通过。 --- .../InterceptorConfiguration.java | 7 +- .../Controller/PersonalController.java | 85 ++++++++++++++ .../Interceptor/PersonalInterceptor.java | 29 ++++- .../lionwebsite/Util/PanelLoginTicket.java | 107 ++++++++++++++++++ src/main/resources/application.yaml | 17 +++ .../Controller/PersonalControllerTest.java | 55 ++++++++- .../Interceptor/InterceptorsTest.java | 27 +++++ .../Util/PanelLoginTicketTest.java | 86 ++++++++++++++ 8 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/lion/lionwebsite/Util/PanelLoginTicket.java create mode 100644 src/test/java/com/lion/lionwebsite/Util/PanelLoginTicketTest.java diff --git a/src/main/java/com/lion/lionwebsite/Configuration/InterceptorConfiguration.java b/src/main/java/com/lion/lionwebsite/Configuration/InterceptorConfiguration.java index 19c71ce..4de5afa 100644 --- a/src/main/java/com/lion/lionwebsite/Configuration/InterceptorConfiguration.java +++ b/src/main/java/com/lion/lionwebsite/Configuration/InterceptorConfiguration.java @@ -17,7 +17,12 @@ public class InterceptorConfiguration implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(getPersonalInterceptor()).addPathPatterns("/personal/**", "/remote/**"); + // 登录端点必须排除在闸门之外,否则校验票据的入口会被自己拦住。 + // /personal/ 只是跳到静态入口 /index,也一并排除:未登录时让前端自己去提示, + // 否则用户直接访问根域名只会看到 401。 + registry.addInterceptor(getPersonalInterceptor()) + .addPathPatterns("/personal/**", "/remote/**") + .excludePathPatterns("/personal/", "/personal/login", "/personal/login/logout", "/personal/denied"); registry.addInterceptor(taskHandlerInterceptor).addPathPatterns("/GalleryManage", "/GalleryManage/**", "/validate"); registry.addInterceptor(getHumanInterceptor()).addPathPatterns("/", "/mobile"); } diff --git a/src/main/java/com/lion/lionwebsite/Controller/PersonalController.java b/src/main/java/com/lion/lionwebsite/Controller/PersonalController.java index 930b216..37ff220 100644 --- a/src/main/java/com/lion/lionwebsite/Controller/PersonalController.java +++ b/src/main/java/com/lion/lionwebsite/Controller/PersonalController.java @@ -2,13 +2,18 @@ package com.lion.lionwebsite.Controller; import com.lion.lionwebsite.Service.LocalService; import com.lion.lionwebsite.Service.PersonalService; +import com.lion.lionwebsite.Util.PanelLoginTicket; import com.lion.lionwebsite.Util.Response; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; @@ -20,15 +25,95 @@ import java.io.IOException; @RequestMapping("/personal") public class PersonalController { + /** 会话有效期,与 PersonalHub 的面板会话保持一致:14 天滑动过期。 */ + private static final int SESSION_MAX_INACTIVE_SECONDS = 60 * 60 * 24 * 14; + final PersonalService personalService; final LocalService localService; + final PanelLoginTicket panelLoginTicket; + @GetMapping("/") public void index(HttpServletResponse resp) throws IOException { resp.sendRedirect("/index"); } + /** + * 一键登录:校验机器人签发的 HMAC 票据,通过后建立会话并跳到个人面板。 + * + *

票据一次性之外的防护全靠有效期,因此失败的票据不重定向到登录页, + * 而是直接回到提示页,避免把参数回显到浏览器历史里。 + */ + @GetMapping("/login") + public void login(@RequestParam(value = "t", required = false) String ticket, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + if (!panelLoginTicket.verify(ticket)) { + log.warn("一键登录票据无效或已过期,来自 {}", clientIp(request)); + // 重定向目标用浏览器可见路径:nginx 的 location / 会把它改写成 /personal/… + response.sendRedirect("/denied"); + return; + } + + // 先作废旧会话再建新的,避免会话固定攻击。 + HttpSession existing = request.getSession(false); + if (existing != null) + existing.invalidate(); + HttpSession session = request.getSession(true); + session.setAttribute("personalAuthenticated", Boolean.TRUE); + session.setMaxInactiveInterval(SESSION_MAX_INACTIVE_SECONDS); + + log.info("一键登录成功,来自 {}", clientIp(request)); + response.sendRedirect("/index"); + } + + @GetMapping("/login/logout") + public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException { + HttpSession session = request.getSession(false); + if (session != null) + session.invalidate(); + response.sendRedirect("/denied"); + } + + /** 未登录或票据失效时的提示页;纯静态文案,不含任何可推断的信息。 */ + @GetMapping(value = "/denied", produces = MediaType.TEXT_HTML_VALUE) + public String denied() { + return """ + + + + + + 需要登录 · LionWebsite + + + +

+

需要登录

+

请在机器人里发送 /login,用返回的一次性链接打开个人面板。

+
+ + + """; + } + + private static String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) + return forwarded.split(",")[0].trim(); + return request.getRemoteAddr(); + } + @PostMapping("/updateSub") public String updateSub() throws IOException { Response response = Response.generateResponse(); diff --git a/src/main/java/com/lion/lionwebsite/Interceptor/PersonalInterceptor.java b/src/main/java/com/lion/lionwebsite/Interceptor/PersonalInterceptor.java index c06a52e..7fb5677 100644 --- a/src/main/java/com/lion/lionwebsite/Interceptor/PersonalInterceptor.java +++ b/src/main/java/com/lion/lionwebsite/Interceptor/PersonalInterceptor.java @@ -2,13 +2,40 @@ package com.lion.lionwebsite.Interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.springframework.web.servlet.HandlerInterceptor; +/** + * 个人管理区的访问闸门:有效会话或合法 AuthCode。 + * + *

会话来自机器人签发的登录链接(见 {@code PanelLoginTicket} 与 + * {@code /personal/login});固定的 {@code alone} 授权码保留给下载器与存储节点推送, + * 因此这里两条路径都放行,等那些调用方切换完成后再退役字面量。 + */ public class PersonalInterceptor implements HandlerInterceptor { + /** 会话标记:{@code /personal/login} 校验票据后写入。 */ + public static final String SESSION_ATTRIBUTE = "personalAuthenticated"; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ - return request.getParameter("AuthCode") != null && request.getParameter("AuthCode").equals("alone"); + if (authenticatedSession(request)) + return true; + + String authCode = request.getParameter("AuthCode"); + if (authCode != null && authCode.equals("alone")) + return true; + + // 会话缺失或过期时回 401,前端据此提示「去机器人发 /login」而不是静默失败。 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + /** 只认服务端写入的会话标记,不信任客户端可伪造的 Cookie 内容。 */ + public static boolean authenticatedSession(HttpServletRequest request) { + // 已有会话才可能已登录;不调用 getSession(),以免为匿名访问创建空会话。 + HttpSession session = request.getSession(false); + return session != null && Boolean.TRUE.equals(session.getAttribute(SESSION_ATTRIBUTE)); } } diff --git a/src/main/java/com/lion/lionwebsite/Util/PanelLoginTicket.java b/src/main/java/com/lion/lionwebsite/Util/PanelLoginTicket.java new file mode 100644 index 0000000..07febfb --- /dev/null +++ b/src/main/java/com/lion/lionwebsite/Util/PanelLoginTicket.java @@ -0,0 +1,107 @@ +package com.lion.lionwebsite.Util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; + +/** + * 机器人一键登录票据:与 PersonalHub 共享密钥的 HMAC 自签串。 + * + *

形状为 {@code v1.<签发秒级时间戳>.") 的十六进制>}。 + * 机器人本地用同一把密钥签出,LionWebsite 侧只做验签与新鲜度判断,因此不需要 + * 机器人到主站的网络调用,两端任一侧临时不可达都不影响链接生成。 + * + *

设计取舍:票据不携带一次性状态,时间窗内可重放(用户明确接受这一点)。 + * 窗口外的票据一律拒绝,所以泄露的链接在 {@code ticket-ttl-seconds} 之后自动失效。 + * + *

票据只签发给主人:密钥只配置在主人自己的机器人上,不随用户分发。 + */ +@Component +@Slf4j +public class PanelLoginTicket { + + private static final String VERSION = "v1"; + private static final String ALGORITHM = "HmacSHA256"; + + private final byte[] secret; + private final Duration ttl; + + public PanelLoginTicket( + @Value("${personal.login.secret:}") String secret, + @Value("${personal.login.ticket-ttl-seconds:300}") long ttlSeconds) { + this.secret = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8); + this.ttl = Duration.ofSeconds(Math.max(30, ttlSeconds)); + if (this.secret.length == 0) + log.warn("未配置 personal.login.secret,一键登录票据将一律拒绝"); + } + + /** 密钥未配置时无法签发也无法校验,调用方据此给出可读提示。 */ + public boolean configured() { + return secret.length > 0; + } + + public String issue() { + return issue(Instant.now()); + } + + /** 供测试注入固定时钟。 */ + public String issue(Instant now) { + if (!configured()) + throw new IllegalStateException("未配置 personal.login.secret"); + String stamp = String.valueOf(now.getEpochSecond()); + return VERSION + "." + stamp + "." + sign(stamp); + } + + public boolean verify(String ticket) { + return verify(ticket, Instant.now()); + } + + public boolean verify(String ticket, Instant now) { + if (!configured() || ticket == null) + return false; + + String[] parts = ticket.split("\\.", -1); + if (parts.length != 3 || !VERSION.equals(parts[0]) || parts[1].isEmpty() || parts[2].isEmpty()) + return false; + + if (!MessageDigest.isEqual(sign(parts[1]).getBytes(StandardCharsets.UTF_8), + parts[2].getBytes(StandardCharsets.UTF_8))) + return false; + + long issuedAt; + try { + issuedAt = Long.parseLong(parts[1]); + } catch (NumberFormatException e) { + return false; + } + + // 只接受 [now - ttl, now]:未来时间戳一律拒绝,避免伪造者用远期时间换取长期有效。 + long ageSeconds = now.getEpochSecond() - issuedAt; + return ageSeconds >= 0 && ageSeconds <= ttl.getSeconds(); + } + + public long ttlSeconds() { + return ttl.getSeconds(); + } + + private String sign(String stamp) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(secret, ALGORITHM)); + return HexFormat.of().formatHex( + mac.doFinal((VERSION + "." + stamp).getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + // 算法名固定且密钥非空,正常不会走到这里;一旦发生必须视为校验失败。 + log.error("计算登录票据签名失败", e); + return ""; + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f37b876..9bb2c84 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,5 +1,15 @@ server: port: 8888 + servlet: + session: + # nginx 的 location / 会把浏览器请求改写成后端 /personal/…,而浏览器地址栏仍是 + # /user 这类路径。若沿用容器按请求路径推导的 Cookie Path(/personal),浏览器 + # 判定 /user 不匹配就不会带上会话,面板会一直 401。这里固定为 /。 + cookie: + path: / + http-only: true + same-site: lax + timeout: 14d tomcat: max-swallow-size: 10000MB http2: @@ -70,3 +80,10 @@ subscription: bot: token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA" + +# 一键登录:机器人用同一密钥自签 HMAC 票据,主站只做验签与时间窗校验。 +# secret 必须通过环境变量注入,不得写入仓库;为空时所有票据一律拒绝。 +personal: + login: + secret: "${PERSONAL_LOGIN_SECRET:}" + ticket-ttl-seconds: 300 diff --git a/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java index 1135025..d5f3864 100644 --- a/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java +++ b/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java @@ -2,8 +2,11 @@ package com.lion.lionwebsite.Controller; import com.lion.lionwebsite.Service.LocalService; import com.lion.lionwebsite.Service.PersonalService; +import com.lion.lionwebsite.Interceptor.PersonalInterceptor; +import com.lion.lionwebsite.Util.PanelLoginTicket; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -21,14 +24,19 @@ class PersonalControllerTest { private PersonalService personalService; private LocalService localService; + private PanelLoginTicket panelLoginTicket; private MockMvc mockMvc; + private static final String SECRET = "test-secret-for-login-ticket"; + @BeforeEach void setUp() { personalService = mock(PersonalService.class); localService = mock(LocalService.class); + // 用真实票据实现(不是 mock),这样验签与时间窗的开销也在链路里被覆盖。 + panelLoginTicket = new PanelLoginTicket(SECRET, 300); mockMvc = MockMvcBuilders - .standaloneSetup(new PersonalController(personalService, localService)) + .standaloneSetup(new PersonalController(personalService, localService, panelLoginTicket)) .build(); } @@ -39,6 +47,51 @@ class PersonalControllerTest { .andExpect(redirectedUrl("/index")); } + // ---------- 一键登录 ---------- + + /** 合法票据:建立会话并跳到面板入口。 */ + @Test + void loginAcceptsFreshTicketAndStartsSession() throws Exception { + var result = mockMvc.perform(get("/personal/login").param("t", panelLoginTicket.issue())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/index")) + .andReturn(); + + var session = (MockHttpSession) result.getRequest().getSession(false); + assertNotNull(session, "登录成功必须建立会话"); + assertTrue(Boolean.TRUE.equals(session.getAttribute(PersonalInterceptor.SESSION_ATTRIBUTE))); + } + + /** 票据缺失、被篡改、或来自未来的时间戳,都必须退回提示页。 */ + @Test + void loginRejectsInvalidTickets() throws Exception { + String valid = panelLoginTicket.issue(); + + for (String bad : new String[]{null, "", "v1.1.2", valid + "x", valid.replace(".", "")}) { + var request = get("/personal/login"); + if (bad != null) + request = request.param("t", bad); + mockMvc.perform(request) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/denied")) + .andExpect(result -> assertNull(result.getRequest().getSession(false), + "非法票据不得建立会话")); + } + } + + /** 退出必须销毁会话。 */ + @Test + void logoutInvalidatesSession() throws Exception { + var session = new MockHttpSession(); + session.setAttribute(PersonalInterceptor.SESSION_ATTRIBUTE, Boolean.TRUE); + + mockMvc.perform(get("/personal/login/logout").session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/denied")); + + assertTrue(session.isInvalid(), "退出后会话必须失效"); + } + @Test void simpleOperationsDelegateWithTheirParameters() throws Exception { mockMvc.perform(get("/personal/lastUpdate")); diff --git a/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java b/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java index 3e76d64..fc50fe4 100644 --- a/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java +++ b/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java @@ -92,6 +92,33 @@ class InterceptorsTest { assertFalse(personal.preHandle(request, new MockHttpServletResponse(), new Object())); } + /** 带已登录会话时无需 AuthCode 即可放行(一键登录换来的会话)。 */ + @Test + void personalAllowsAuthenticatedSessionWithoutAuthCode() { + var request = new MockHttpServletRequest(); + request.getSession(true).setAttribute(PersonalInterceptor.SESSION_ATTRIBUTE, Boolean.TRUE); + + assertTrue(personal.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + /** 会话存在但没有登录标记时不得放行:只有服务端写入的标记才算数。 */ + @Test + void personalRejectsSessionWithoutMarker() { + var request = new MockHttpServletRequest(); + request.getSession(true); + + assertFalse(personal.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + /** 拒绝时必须回 401,前端据此提示去机器人要新链接。 */ + @Test + void personalReturnsUnauthorizedWhenRejected() { + var response = new MockHttpServletResponse(); + + assertFalse(personal.preHandle(new MockHttpServletRequest(), response, new Object())); + assertEquals(401, response.getStatus()); + } + // ---------- TaskHandlerInterceptor ---------- /** diff --git a/src/test/java/com/lion/lionwebsite/Util/PanelLoginTicketTest.java b/src/test/java/com/lion/lionwebsite/Util/PanelLoginTicketTest.java new file mode 100644 index 0000000..16bf3ee --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Util/PanelLoginTicketTest.java @@ -0,0 +1,86 @@ +package com.lion.lionwebsite.Util; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 一键登录票据的签发与校验。 + * + *

这是「不经过网络调用」那条路径的唯一安全边界:机器人用共享密钥自签,主站只验签 + * 与看时间窗。因此验签、篡改、过期、未来时间戳四个方向都必须单独锁住。 + */ +class PanelLoginTicketTest { + + private static final String SECRET = "unit-test-secret"; + private static final Instant NOW = Instant.parse("2026-09-21T02:00:00Z"); + + private final PanelLoginTicket ticket = new PanelLoginTicket(SECRET, 300); + + @Test + void issuesAndAcceptsFreshTicket() { + String issued = ticket.issue(NOW); + + assertTrue(ticket.verify(issued, NOW)); + // 窗口内可重放(用户明确接受):同一张票据第二次仍然有效。 + assertTrue(ticket.verify(issued, NOW.plusSeconds(10))); + } + + /** 到期即失效,这是没有一次性状态时唯一的兜底。 */ + @Test + void rejectsExpiredTicket() { + String issued = ticket.issue(NOW); + + assertTrue(ticket.verify(issued, NOW.plusSeconds(300))); + assertFalse(ticket.verify(issued, NOW.plusSeconds(301))); + } + + /** 未来时间戳必须拒绝,否则伪造者可以用远期时间换一张长期有效的票据。 */ + @Test + void rejectsFutureTimestamp() { + assertFalse(ticket.verify(ticket.issue(NOW.plusSeconds(60)), NOW)); + } + + /** 换密钥后旧票据立即失效(用于轮换)。 */ + @Test + void rejectsTicketSignedWithAnotherSecret() { + String other = new PanelLoginTicket("different-secret", 300).issue(NOW); + + assertFalse(ticket.verify(other, NOW)); + } + + @Test + void rejectsMalformedTickets() { + String valid = ticket.issue(NOW); + + assertFalse(ticket.verify(null, NOW)); + assertFalse(ticket.verify("", NOW)); + assertFalse(ticket.verify("v1", NOW)); + assertFalse(ticket.verify("v1.1.2.3", NOW)); + assertFalse(ticket.verify("v2." + NOW.getEpochSecond() + ".deadbeef", NOW)); + assertFalse(ticket.verify(valid.toUpperCase(), NOW)); + assertFalse(ticket.verify(valid + "0", NOW)); + // 时间戳非数字 + assertFalse(ticket.verify("v1.abc.deadbeef", NOW)); + } + + /** 未配置密钥时不签发也不放行,避免“忘记配置就等于全开放”。 */ + @Test + void unconfiguredSecretRejectsEverything() { + var unconfigured = new PanelLoginTicket("", 300); + + assertFalse(unconfigured.configured()); + assertFalse(unconfigured.verify("v1.1.deadbeef", NOW)); + assertThrows(IllegalStateException.class, unconfigured::issue); + } + + /** TTL 有下限,防止把窗口配成 0 而让登录永远失败。 */ + @Test + void ttlHasFloor() { + assertEquals(30, new PanelLoginTicket(SECRET, 0).ttlSeconds()); + assertEquals(30, new PanelLoginTicket(SECRET, -100).ttlSeconds()); + assertEquals(600, new PanelLoginTicket(SECRET, 600).ttlSeconds()); + } +}