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