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 """ + + +
+ + +请在机器人里发送 /login,用返回的一次性链接打开个人面板。
会话来自机器人签发的登录链接(见 {@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.<签发秒级时间戳>. 设计取舍:票据不携带一次性状态,时间窗内可重放(用户明确接受这一点)。
+ * 窗口外的票据一律拒绝,所以泄露的链接在 {@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());
+ }
+}