From 2cabf76c2d5903db12c37e40696c712fc1d1ee02 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 18 May 2026 11:19:44 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=A2=9E=E5=8A=A0fail2ban?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/login/route.ts | 29 ++++++ src/lib/login-fail2ban.ts | 175 +++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/lib/login-fail2ban.ts diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index aad2a47..41bbc91 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -4,6 +4,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseAuthInfo } from '@/lib/auth'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; +import { + checkLoginBan, + getLoginClientIp, + recordLoginFailure, + recordLoginSuccess, +} from '@/lib/login-fail2ban'; import { generateRefreshToken, generateTokenId, @@ -177,6 +183,20 @@ function getDeviceInfo(request: NextRequest): string { export async function POST(req: NextRequest) { try { + const clientIp = getLoginClientIp(req); + const banStatus = checkLoginBan(clientIp); + if (banStatus.banned) { + return NextResponse.json( + { error: '登录失败次数过多,请稍后再试' }, + { + status: 429, + headers: banStatus.retryAfterSeconds + ? { 'Retry-After': String(banStatus.retryAfterSeconds) } + : undefined, + } + ); + } + // 获取站点配置 const adminConfig = await getConfig(); const siteConfig = adminConfig.SiteConfig; @@ -206,12 +226,15 @@ export async function POST(req: NextRequest) { } if (password !== envPassword) { + recordLoginFailure(clientIp); return NextResponse.json( { ok: false, error: '密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const username = process.env.USERNAME || 'default'; const deviceInfo = getDeviceInfo(req); @@ -279,6 +302,8 @@ export async function POST(req: NextRequest) { username === process.env.USERNAME && password === process.env.PASSWORD ) { + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( @@ -302,6 +327,7 @@ export async function POST(req: NextRequest) { return response; } else if (username === process.env.USERNAME) { + recordLoginFailure(clientIp); return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }); } @@ -326,12 +352,15 @@ export async function POST(req: NextRequest) { } if (!pass) { + recordLoginFailure(clientIp); return NextResponse.json( { error: '用户名或密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( diff --git a/src/lib/login-fail2ban.ts b/src/lib/login-fail2ban.ts new file mode 100644 index 0000000..c44d354 --- /dev/null +++ b/src/lib/login-fail2ban.ts @@ -0,0 +1,175 @@ +import { NextRequest } from 'next/server'; + +const WINDOW_MS = 10 * 60 * 1000; +const MAX_FAILURES = 5; +const BAN_DURATIONS_MS = [ + 60 * 60 * 1000, + 6 * 60 * 60 * 1000, + 24 * 60 * 60 * 1000, +]; +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; +const RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +interface LoginFail2BanRecord { + failures: number[]; + bannedUntil: number; + banLevel: number; + lastSeen: number; +} + +interface LoginFail2BanStore { + records: Map; + lastCleanup: number; +} + +interface LoginFail2BanGlobal { + __loginFail2BanStore?: LoginFail2BanStore; +} + +export interface LoginBanStatus { + banned: boolean; + bannedUntil?: number; + retryAfterSeconds?: number; +} + +export interface LoginFailureResult extends LoginBanStatus { + failureCount: number; + banLevel: number; +} + +function getStore(): LoginFail2BanStore { + const globalStore = globalThis as typeof globalThis & LoginFail2BanGlobal; + if (!globalStore.__loginFail2BanStore) { + globalStore.__loginFail2BanStore = { + records: new Map(), + lastCleanup: Date.now(), + }; + } + + return globalStore.__loginFail2BanStore; +} + +function cleanupExpiredRecords(now: number) { + const store = getStore(); + if (now - store.lastCleanup < CLEANUP_INTERVAL_MS) return; + + for (const [ip, record] of Array.from(store.records.entries())) { + const hasActiveBan = record.bannedUntil > now; + const recentlySeen = now - record.lastSeen < RECORD_TTL_MS; + + if (!hasActiveBan && !recentlySeen) { + store.records.delete(ip); + } + } + + store.lastCleanup = now; +} + +function pruneFailures(record: LoginFail2BanRecord, now: number) { + record.failures = record.failures.filter((time) => now - time <= WINDOW_MS); +} + +function normalizeIp(ip: string): string | null { + const normalized = ip.trim(); + if (!normalized || normalized.toLowerCase() === 'unknown') return null; + return normalized; +} + +export function getLoginClientIp(req: NextRequest): string | null { + const cfConnectingIp = normalizeIp(req.headers.get('cf-connecting-ip') || ''); + if (cfConnectingIp) return cfConnectingIp; + + const xRealIp = normalizeIp(req.headers.get('x-real-ip') || ''); + if (xRealIp) return xRealIp; + + const xForwardedFor = req.headers.get('x-forwarded-for'); + if (xForwardedFor) { + const firstIp = normalizeIp(xForwardedFor.split(',')[0] || ''); + if (firstIp) return firstIp; + } + + const forwarded = req.headers.get('forwarded'); + if (forwarded) { + const match = forwarded.match(/for=(?:"?)([^;,\"]+)/i); + const forwardedIp = normalizeIp(match?.[1]?.replace(/^\[|\]$/g, '') || ''); + if (forwardedIp) return forwardedIp; + } + + return null; +} + +export function checkLoginBan(ip: string | null, now = Date.now()): LoginBanStatus { + if (!ip) return { banned: false }; + + cleanupExpiredRecords(now); + + const record = getStore().records.get(ip); + if (!record) return { banned: false }; + + record.lastSeen = now; + if (record.bannedUntil > now) { + return { + banned: true, + bannedUntil: record.bannedUntil, + retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000), + }; + } + + pruneFailures(record, now); + return { banned: false }; +} + +export function recordLoginFailure(ip: string | null, now = Date.now()): LoginFailureResult { + if (!ip) { + return { banned: false, failureCount: 0, banLevel: 0 }; + } + + cleanupExpiredRecords(now); + + const store = getStore(); + const record = store.records.get(ip) || { + failures: [], + bannedUntil: 0, + banLevel: 0, + lastSeen: now, + }; + + record.lastSeen = now; + pruneFailures(record, now); + record.failures.push(now); + + if (record.failures.length >= MAX_FAILURES) { + record.banLevel += 1; + const durationIndex = Math.min(record.banLevel - 1, BAN_DURATIONS_MS.length - 1); + record.bannedUntil = now + BAN_DURATIONS_MS[durationIndex]; + record.failures = []; + } + + store.records.set(ip, record); + + if (record.bannedUntil > now) { + return { + banned: true, + bannedUntil: record.bannedUntil, + retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000), + failureCount: record.failures.length, + banLevel: record.banLevel, + }; + } + + return { + banned: false, + failureCount: record.failures.length, + banLevel: record.banLevel, + }; +} + +export function recordLoginSuccess(ip: string | null, now = Date.now()) { + if (!ip) return; + + const record = getStore().records.get(ip); + if (!record) return; + + record.failures = []; + record.lastSeen = now; +}