新增一键登录:共享密钥自签票据换会话
- 新增 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:
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user