Commit a7af7cdf authored by xuchentao's avatar xuchentao

feat: add captcha to admin login

parent 5b53d77f
Pipeline #415 passed with stages
in 17 seconds
......@@ -22,6 +22,17 @@ async function api(path, options = {}) {
const show = (selector) => $(selector).classList.remove("hidden");
const hide = (selector) => $(selector).classList.add("hidden");
const refreshCaptcha = () => {
$("#captcha-image").src = `${API_BASE}/captcha?t=${Date.now()}`;
$("#captcha").value = "";
};
function showLogin() {
show("#login");
hide("#app");
refreshCaptcha();
$("#password").focus();
}
async function boot() {
const session = await api("/session").catch(() => ({ authed: false }));
......@@ -30,19 +41,25 @@ async function boot() {
await loadCategories();
loadArticles();
}
else { show("#login"); hide("#app"); }
else showLogin();
}
$("#captcha-image").addEventListener("click", refreshCaptcha);
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#login-error").textContent = "";
try {
await api("/session", { method: "POST", body: { password: $("#password").value } });
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value } });
$("#password").value = "";
$("#captcha").value = "";
hide("#login"); show("#app");
await loadCategories();
loadArticles();
} catch (error) { $("#login-error").textContent = error.message; }
} catch (error) {
$("#login-error").textContent = error.message;
refreshCaptcha();
}
});
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); });
......
......@@ -28,6 +28,9 @@ button, .button { min-height: 40px; padding: 0 17px; display: inline-flex; align
.login-card h1 { margin: 0; font-size: 30px; }
.login-card p { margin: -8px 0 4px; color: var(--muted); }
.login-card label, .field-panel label, .field-label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.captcha-row { display: flex; align-items: stretch; gap: 10px; }
.captcha-row input { min-width: 0; flex: 1; text-transform: uppercase; }
#captcha-image { width: 132px; height: 46px; flex: 0 0 auto; cursor: pointer; background: var(--light); border: 1px solid var(--border); border-radius: 9px; }
input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); background: white; border: 1px solid var(--border); border-radius: 9px; }
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(22,174,156,.14); border-color: var(--primary); }
.topbar { min-height: 68px; padding: 12px 28px; position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 22px; color: white; background: #101413; }
......
......@@ -25,7 +25,12 @@ import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../
const PASSWORD = process.env.CMS_PASSWORD || "admin";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
const loginAttempts = new Map<string, { count: number; until: number }>();
const CAPTCHA_TTL = 5 * 60 * 1000;
const CAPTCHA_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const MAX_LOGIN_FAILURES = 5;
const LOGIN_FAILURE_WINDOW = 10 * 60 * 1000;
const LOGIN_BLOCK_DURATION = 10 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number; blockedUntil: number }>();
type BuildState = {
status: "idle" | "building" | "success" | "error";
ok: boolean | null;
......@@ -66,24 +71,75 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; }
}
function cookieHeader(request: Request, value: string, maxAge: number): string {
function cookieHeader(request: Request, name: string, value: string, maxAge: number): string {
const forwarded = request.headers.get("x-forwarded-proto");
const secure = new URL(request.url).protocol === "https:" || forwarded === "https";
return `cms_session=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
return `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
}
function loginAllowed(ip: string): boolean {
function loginBlockedFor(ip: string): number {
const state = loginAttempts.get(ip);
return !state || state.until < Date.now();
return state?.blockedUntil && state.blockedUntil > Date.now()
? Math.ceil((state.blockedUntil - Date.now()) / 1000)
: 0;
}
function loginFailed(ip: string): void {
const state = loginAttempts.get(ip) || { count: 0, until: 0 };
const now = Date.now();
let state = loginAttempts.get(ip);
if (!state || now - state.first > LOGIN_FAILURE_WINDOW) {
state = { count: 0, first: now, blockedUntil: 0 };
}
state.count += 1;
if (state.count >= 5) state.until = Date.now() + 10 * 60 * 1000;
if (state.count >= MAX_LOGIN_FAILURES) state.blockedUntil = now + LOGIN_BLOCK_DURATION;
loginAttempts.set(ip, state);
}
function randomCaptcha(length = 4): string {
let code = "";
for (let index = 0; index < length; index += 1) {
code += CAPTCHA_CHARS[crypto.randomInt(CAPTCHA_CHARS.length)];
}
return code;
}
function captchaMac(code: unknown, expiresAt: number): string {
return crypto.createHmac("sha256", SECRET)
.update(`${String(code).toUpperCase()}|${expiresAt}`)
.digest("hex");
}
function captchaValid(request: Request, answer: unknown): boolean {
const value = cookies(request).cms_captcha;
if (!value || !answer) return false;
const separator = value.lastIndexOf(".");
const expiresAt = Number(value.slice(0, separator));
const mac = value.slice(separator + 1);
return Boolean(expiresAt && Date.now() <= expiresAt && safeEqual(mac, captchaMac(answer, expiresAt)));
}
function captchaSvg(code: string): string {
const width = 132;
const height = 46;
const colors = ["#11897b", "#0e727c", "#16ae9c", "#243532"];
const randomBetween = (min: number, max: number) => crypto.randomInt(min, max + 1);
const pick = <T>(items: T[]): T => items[crypto.randomInt(items.length)];
let content = `<rect width="${width}" height="${height}" fill="#e4efea"/>`;
for (let index = 0; index < 4; index += 1) {
content += `<line x1="${randomBetween(0, width)}" y1="${randomBetween(0, height)}" x2="${randomBetween(0, width)}" y2="${randomBetween(0, height)}" stroke="${pick(colors)}" stroke-width="1" opacity="0.45"/>`;
}
[...code].forEach((character, index) => {
const x = 16 + index * 28 + randomBetween(-3, 3);
const y = 31 + randomBetween(-4, 4);
const rotation = randomBetween(-26, 26);
content += `<text x="${x}" y="${y}" font-family="Georgia,serif" font-weight="700" font-size="${randomBetween(24, 30)}" fill="${pick(colors)}" transform="rotate(${rotation} ${x} ${y})">${character}</text>`;
});
for (let index = 0; index < 26; index += 1) {
content += `<circle cx="${randomBetween(0, width)}" cy="${randomBetween(0, height)}" r="1" fill="${pick(colors)}" opacity="0.5"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
}
function requireSlug(value: string | undefined): string {
const slug = safeSlug(value);
if (!slug) throw new StoreError("网址标识不合法");
......@@ -141,19 +197,39 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
const method = request.method.toUpperCase();
try {
if (route === "captcha" && method === "GET") {
const code = randomCaptcha();
const expiresAt = Date.now() + CAPTCHA_TTL;
return new Response(captchaSvg(code), {
headers: {
"Cache-Control": "no-store",
"Content-Type": "image/svg+xml; charset=utf-8",
"Set-Cookie": cookieHeader(request, "cms_captcha", `${expiresAt}.${captchaMac(code, expiresAt)}`, CAPTCHA_TTL / 1000),
},
});
}
if (route === "session") {
if (method === "GET") return json({ authed: authed(request) });
if (method === "POST") {
if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429);
const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
const body = await bodyOf(request);
const clearCaptcha = cookieHeader(request, "cms_captcha", "", 0);
if (!captchaValid(request, body.captcha)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
}
if (!safeEqual(body.password || "", PASSWORD)) {
loginFailed(clientAddress);
throw new StoreError("管理密码错误", 401);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha });
}
loginAttempts.delete(clientAddress);
return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, sessionToken(), 86400) });
const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha);
headers.append("Set-Cookie", cookieHeader(request, "cms_session", sessionToken(), 86400));
return json({ ok: true }, 200, headers);
}
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "", 0) });
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
}
if (!authed(request)) throw new StoreError("请先登录文章后台", 401);
......
......@@ -12,8 +12,13 @@
<form id="login-form" class="login-card">
<div class="brand-mark">塑美俏</div>
<h1>健康资讯后台</h1>
<p>输入管理密码,管理官网文章内容。</p>
<p>输入管理密码和图片验证码,管理官网文章内容。</p>
<label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label>
<label>图片验证码</label>
<div class="captcha-row">
<input id="captcha" autocomplete="off" maxlength="4" inputmode="text" aria-label="图片验证码" required />
<img id="captcha-image" alt="图片验证码,点击可更换" title="看不清?点击换一张" />
</div>
<button class="primary" type="submit">登录后台</button>
<div id="login-error" class="error"></div>
</form>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment