feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Console 用户认证:用户名密码登录 + HMAC 签名会话 cookie。
|
||||
* cookie 值: base64url(payload) "." hex(HMAC-SHA256(sessionSecret, payload))
|
||||
*/
|
||||
|
||||
import { hmacSha256Hex, timingSafeEqualHex, verifyPassword } from "../../shared/src/index.ts";
|
||||
import type { Env } from "./env.ts";
|
||||
import { getSessionSecret } from "./db.ts";
|
||||
import { nowIso, readJson } from "./util.ts";
|
||||
import { Hono } from "hono";
|
||||
|
||||
/** # 成员变量: 会话 cookie 名称 **/
|
||||
export const SESSION_COOKIE = "wmh_session";
|
||||
|
||||
/** # 成员变量: 会话有效期(秒),默认 7 天 **/
|
||||
const SESSION_TTL_SECONDS = 7 * 24 * 3600;
|
||||
|
||||
/** # 成员变量: 会话 payload **/
|
||||
interface SessionPayload {
|
||||
uid: string;
|
||||
username: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 签发会话 token
|
||||
*/
|
||||
export async function issueSessionToken(env: Env, uid: string, username: string): Promise<string> {
|
||||
const payload: SessionPayload = {
|
||||
uid,
|
||||
username,
|
||||
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
|
||||
};
|
||||
const body = base64UrlEncode(JSON.stringify(payload));
|
||||
const secret = await getSessionSecret(env);
|
||||
const sig = await hmacSha256Hex(secret, body);
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 校验会话 token,返回 payload;无效/过期返回 null
|
||||
*/
|
||||
export async function verifySessionToken(
|
||||
env: Env,
|
||||
token: string | undefined,
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const secret = await getSessionSecret(env);
|
||||
const expected = await hmacSha256Hex(secret, body);
|
||||
if (!timingSafeEqualHex(expected, sig)) return null;
|
||||
let payload: SessionPayload;
|
||||
try {
|
||||
payload = JSON.parse(base64UrlDecode(body)) as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload?.uid || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 从 Request 中取会话 cookie
|
||||
*/
|
||||
export function readSessionCookie(request: Request): string | undefined {
|
||||
const cookie = request.headers.get("Cookie") ?? "";
|
||||
for (const part of cookie.split(";")) {
|
||||
const [k, ...rest] = part.trim().split("=");
|
||||
if (k === SESSION_COOKIE) return rest.join("=");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造 Set-Cookie 会话头
|
||||
*/
|
||||
export function sessionSetCookie(token: string): string {
|
||||
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_SECONDS}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造清除会话 cookie 头
|
||||
*/
|
||||
export function clearSessionCookie(): string {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
||||
}
|
||||
|
||||
function base64UrlEncode(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): string {
|
||||
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配认证路由:POST /login、POST /logout、GET /me
|
||||
*/
|
||||
export function authRoutes() {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
/** 登录:用户名密码 → 签名会话 cookie。默认账号 admin/admin(首次启动自动播种) */
|
||||
app.post("/login", async (c) => {
|
||||
const { username, password } = await readJson<{ username?: string; password?: string }>(c.req.raw);
|
||||
if (!username || !password) {
|
||||
return c.json({ error: "username and password required" }, 400);
|
||||
}
|
||||
const user = await c.env.DB.prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.first<{ id: string; username: string; password_hash: string }>();
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return c.json({ error: "invalid username or password" }, 401);
|
||||
}
|
||||
const token = await issueSessionToken(c.env, user.id, user.username);
|
||||
c.header("Set-Cookie", sessionSetCookie(token));
|
||||
return c.json({ ok: true, user: { id: user.id, username: user.username } });
|
||||
});
|
||||
|
||||
/** 登出:清除会话 cookie */
|
||||
app.post("/logout", (c) => {
|
||||
c.header("Set-Cookie", clearSessionCookie());
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
app.get("/me", async (c) => {
|
||||
const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw));
|
||||
if (!payload) return c.json({ error: "unauthorized" }, 401);
|
||||
return c.json({ user: { id: payload.uid, username: payload.username }, now: nowIso() });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user