登录增加fail2ban机制
This commit is contained in:
@@ -4,6 +4,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { parseAuthInfo } from '@/lib/auth';
|
import { parseAuthInfo } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
checkLoginBan,
|
||||||
|
getLoginClientIp,
|
||||||
|
recordLoginFailure,
|
||||||
|
recordLoginSuccess,
|
||||||
|
} from '@/lib/login-fail2ban';
|
||||||
import {
|
import {
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
generateTokenId,
|
generateTokenId,
|
||||||
@@ -177,6 +183,20 @@ function getDeviceInfo(request: NextRequest): string {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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 adminConfig = await getConfig();
|
||||||
const siteConfig = adminConfig.SiteConfig;
|
const siteConfig = adminConfig.SiteConfig;
|
||||||
@@ -206,12 +226,15 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (password !== envPassword) {
|
if (password !== envPassword) {
|
||||||
|
recordLoginFailure(clientIp);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ ok: false, error: '密码错误' },
|
{ ok: false, error: '密码错误' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordLoginSuccess(clientIp);
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const username = process.env.USERNAME || 'default';
|
const username = process.env.USERNAME || 'default';
|
||||||
const deviceInfo = getDeviceInfo(req);
|
const deviceInfo = getDeviceInfo(req);
|
||||||
@@ -279,6 +302,8 @@ export async function POST(req: NextRequest) {
|
|||||||
username === process.env.USERNAME &&
|
username === process.env.USERNAME &&
|
||||||
password === process.env.PASSWORD
|
password === process.env.PASSWORD
|
||||||
) {
|
) {
|
||||||
|
recordLoginSuccess(clientIp);
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const deviceInfo = getDeviceInfo(req);
|
const deviceInfo = getDeviceInfo(req);
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(
|
||||||
@@ -302,6 +327,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} else if (username === process.env.USERNAME) {
|
} else if (username === process.env.USERNAME) {
|
||||||
|
recordLoginFailure(clientIp);
|
||||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,12 +352,15 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pass) {
|
if (!pass) {
|
||||||
|
recordLoginFailure(clientIp);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '用户名或密码错误' },
|
{ error: '用户名或密码错误' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordLoginSuccess(clientIp);
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const deviceInfo = getDeviceInfo(req);
|
const deviceInfo = getDeviceInfo(req);
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(
|
||||||
|
|||||||
@@ -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<string, LoginFail2BanRecord>;
|
||||||
|
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<string, LoginFail2BanRecord>(),
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user