新增一键登录:共享密钥自签票据换会话

- 新增 PanelLoginTicket:v1.<时间戳>.<HMAC-SHA256> 形状,只验签与时间窗,
  不发一次性状态。机器人用同一把密钥本地签发,因此不需要机器人到主站的网络调用,
  一端临时不可达也不影响生成链接。窗口内可重放(用户明确接受),过期即失效。
- 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 项通过。
This commit is contained in:
root
2026-09-21 10:20:51 +08:00
parent 654181d2ea
commit 00e27152fe
8 changed files with 410 additions and 3 deletions
@@ -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");
}
@@ -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 票据,通过后建立会话并跳到个人面板。
*
* <p>票据一次性之外的防护全靠有效期,因此失败的票据不重定向到登录页,
* 而是直接回到提示页,避免把参数回显到浏览器历史里。
*/
@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 """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>需要登录 · LionWebsite</title>
<style>
body { margin:0; display:flex; min-height:100vh; align-items:center; justify-content:center;
background:#f6f7f9; color:#1f2328;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; }
.card { max-width:26rem; padding:2rem; background:#fff; border:1px solid #e2e5e9;
border-radius:10px; box-shadow:0 1px 2px rgba(0,0,0,.04); }
h1 { margin:0 0 .75rem; font-size:1.15rem; }
p { margin:0; line-height:1.7; color:#4a5259; }
code { background:#f0f2f5; padding:.1rem .35rem; border-radius:4px; }
</style>
</head>
<body>
<div class="card">
<h1>需要登录</h1>
<p>请在机器人里发送 <code>/login</code>,用返回的一次性链接打开个人面板。</p>
</div>
</body>
</html>
""";
}
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();
@@ -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。
*
* <p>会话来自机器人签发的登录链接(见 {@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));
}
}
@@ -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 自签串。
*
* <p>形状为 {@code v1.<签发秒级时间戳>.<HMAC-SHA256(密钥, "v1.<时间戳>") 的十六进制>}。
* 机器人本地用同一把密钥签出,LionWebsite 侧只做验签与新鲜度判断,因此不需要
* 机器人到主站的网络调用,两端任一侧临时不可达都不影响链接生成。
*
* <p>设计取舍:票据不携带一次性状态,时间窗内可重放(用户明确接受这一点)。
* 窗口外的票据一律拒绝,所以泄露的链接在 {@code ticket-ttl-seconds} 之后自动失效。
*
* <p>票据只签发给主人:密钥只配置在主人自己的机器人上,不随用户分发。
*/
@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 "";
}
}
}
+17
View File
@@ -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
@@ -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"));
@@ -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 ----------
/**
@@ -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.*;
/**
* 一键登录票据的签发与校验。
*
* <p>这是「不经过网络调用」那条路径的唯一安全边界:机器人用共享密钥自签,主站只验签
* 与看时间窗。因此验签、篡改、过期、未来时间戳四个方向都必须单独锁住。
*/
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());
}
}