170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
// 人机验证(自研「一键验证」)
|
||||
|
|
//
|
|||
|
|
// 设计目标:正常读者**无感**(浏览器静默算一个 20~80ms 的 PoW,什么也不用手动做),
|
|||
|
|
// 命中可疑信号时才让访客「点一下」,再可疑才回退到图形验证码。
|
|||
|
|
//
|
|||
|
|
// 为什么不用 Turnstile/reCAPTCHA:
|
|||
|
|
// 1) 国内加载不稳定(Google/Cloudflare 的脚本经常拉不下来),一旦失败评论就发不出去;
|
|||
|
|
// 2) 会把访客 IP/浏览器指纹送给第三方。
|
|||
|
|
// 自研版靠 PoW + 行为信号,挡得住脚本刷评论和低成本机器人,对个人博客足够。
|
|||
|
|
//
|
|||
|
|
// 关键点:**挑战是无状态的**(HMAC 签名,不写库);通行证放 KV(不占 D1 额度)。
|
|||
|
|
|
|||
|
|
import type { Env } from '../types';
|
|||
|
|
|
|||
|
|
/** PoW 难度:sha256(challenge + nonce) 的十六进制前缀要有这么多个 0 */
|
|||
|
|
export const POW_DIFFICULTY = 4;
|
|||
|
|
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
|||
|
|
/** 通行证有效期(秒):过了一次就不用再验 */
|
|||
|
|
const PASS_TTL_SEC = 1800;
|
|||
|
|
/** 开关:存 KV,读一次比读 D1 settings 便宜 */
|
|||
|
|
const ENABLED_KEY = 'human_check';
|
|||
|
|
|
|||
|
|
export interface HumanChallenge {
|
|||
|
|
challenge: string;
|
|||
|
|
difficulty: number;
|
|||
|
|
exp: number;
|
|||
|
|
sig: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bytesToHex(buf: ArrayBuffer): string {
|
|||
|
|
return [...new Uint8Array(buf)]
|
|||
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|||
|
|
.join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function sha256Hex(input: string): Promise<string> {
|
|||
|
|
const data = new TextEncoder().encode(input);
|
|||
|
|
return bytesToHex(await crypto.subtle.digest('SHA-256', data));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function hmacHex(secret: string, msg: string): Promise<string> {
|
|||
|
|
const key = await crypto.subtle.importKey(
|
|||
|
|
'raw',
|
|||
|
|
new TextEncoder().encode(secret),
|
|||
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|||
|
|
false,
|
|||
|
|
['sign'],
|
|||
|
|
);
|
|||
|
|
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(msg));
|
|||
|
|
return bytesToHex(sig);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ------------------------------------------------------------------ 开关
|
|||
|
|
|
|||
|
|
export async function isHumanCheckEnabled(env: Env): Promise<boolean> {
|
|||
|
|
try {
|
|||
|
|
return (await env.RSS_KV.get(ENABLED_KEY)) === '1';
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function setHumanCheck(env: Env, on: boolean): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0');
|
|||
|
|
} catch {
|
|||
|
|
/* 开关写失败不影响主流程 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- 挑战签发
|
|||
|
|
|
|||
|
|
export async function issueChallenge(env: Env): Promise<HumanChallenge> {
|
|||
|
|
const buf = crypto.getRandomValues(new Uint8Array(12));
|
|||
|
|
const challenge = bytesToHex(buf.buffer);
|
|||
|
|
const exp = Date.now() + CHALLENGE_TTL_MS;
|
|||
|
|
const sig = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
|
|||
|
|
return { challenge, difficulty: POW_DIFFICULTY, exp, sig };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 校验 PoW 证明:签名有效 + 未过期 + 哈希前缀达标 */
|
|||
|
|
export async function verifyProof(
|
|||
|
|
env: Env,
|
|||
|
|
p: { challenge?: string; nonce?: number | string; exp?: number; sig?: string },
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
const challenge = String(p.challenge || '');
|
|||
|
|
const exp = Number(p.exp || 0);
|
|||
|
|
const sig = String(p.sig || '');
|
|||
|
|
if (!challenge || !exp || !sig || p.nonce === undefined || p.nonce === null) return false;
|
|||
|
|
if (Date.now() > exp) return false;
|
|||
|
|
const expect = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
|
|||
|
|
if (expect !== sig) return false;
|
|||
|
|
const hash = await sha256Hex(`${challenge}${p.nonce}`);
|
|||
|
|
return hash.startsWith('0'.repeat(POW_DIFFICULTY));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- 通行证
|
|||
|
|
|
|||
|
|
export function humanPassKey(ip: string): string {
|
|||
|
|
return `human:pass:${ip}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function hasHumanPass(env: Env, ip: string): Promise<boolean> {
|
|||
|
|
try {
|
|||
|
|
return (await env.RSS_KV.get(humanPassKey(ip))) === '1';
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function grantHumanPass(env: Env, ip: string, ttl = PASS_TTL_SEC): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
await env.RSS_KV.put(humanPassKey(ip), '1', { expirationTtl: ttl });
|
|||
|
|
} catch {
|
|||
|
|
/* 通行证写失败 → 下次再验,不影响本次放行 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ------------------------------------------------------------ 行为信号评分
|
|||
|
|
|
|||
|
|
export interface HumanSignals {
|
|||
|
|
/** 蜜罐字段:人类看不见也不会填,填了就一定是脚本 */
|
|||
|
|
honeypot?: string;
|
|||
|
|
/** 从拿到挑战到提交验证的耗时(毫秒) */
|
|||
|
|
elapsedMs?: number;
|
|||
|
|
/** 页面上的鼠标/键盘/滚动/触摸事件次数 */
|
|||
|
|
events?: number;
|
|||
|
|
/** navigator.webdriver(自动化浏览器通常为 true) */
|
|||
|
|
webdriver?: boolean;
|
|||
|
|
/** 是否是「点一下」触发的验证(点击本身就是人类信号) */
|
|||
|
|
clicked?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type RiskLevel = 'low' | 'medium' | 'high';
|
|||
|
|
|
|||
|
|
export interface RiskResult {
|
|||
|
|
level: RiskLevel;
|
|||
|
|
reasons: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 信号评分。
|
|||
|
|
* low → 直接发通行证(读者无感)
|
|||
|
|
* medium → 要求「点一下」(点击会补齐 elapsedMs / events 信号,重试即通过)
|
|||
|
|
* high → 回退图形验证码
|
|||
|
|
*/
|
|||
|
|
export function assessSignals(s: HumanSignals): RiskResult {
|
|||
|
|
const reasons: string[] = [];
|
|||
|
|
if (s.honeypot) {
|
|||
|
|
return { level: 'high', reasons: ['honeypot filled'] };
|
|||
|
|
}
|
|||
|
|
if (s.webdriver === true) {
|
|||
|
|
reasons.push('webdriver');
|
|||
|
|
}
|
|||
|
|
const elapsed = Number(s.elapsedMs || 0);
|
|||
|
|
const events = Number(s.events || 0);
|
|||
|
|
if (elapsed > 0 && elapsed < 1200) reasons.push('too fast');
|
|||
|
|
if (elapsed > 2 * 3600 * 1000) reasons.push('too slow');
|
|||
|
|
if (events <= 0 && !s.clicked) reasons.push('no interaction');
|
|||
|
|
|
|||
|
|
if (reasons.includes('webdriver')) return { level: 'high', reasons };
|
|||
|
|
if (s.clicked) {
|
|||
|
|
// 点击过就直接放行(点击 + PoW 已经足够;elapsed 太短仍视为可疑)
|
|||
|
|
return reasons.includes('too fast') && elapsed < 400
|
|||
|
|
? { level: 'medium', reasons }
|
|||
|
|
: { level: 'low', reasons };
|
|||
|
|
}
|
|||
|
|
if (reasons.length) return { level: 'medium', reasons };
|
|||
|
|
return { level: 'low', reasons: [] };
|
|||
|
|
}
|