mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-10 01:08:08 +08:00
feat(security): account+IP login failure lockout and IP limit fail-closed
- Add loginLockout helpers on frequency_limit collection (assert, record, clear, audit log) - Wire loginByPassword: lock before auth, count auth/password failures, clear on success - useIPFrequencyLimit failClosed + authFrequencyLimit strict for Mongo errors - Centralize PASSWORD_LOGIN_LOCK_SECONDS / LOGIN_FAIL_* in env.ts; slim type/env ProcessEnv - Extend loginByPassword API tests (lockout via stubEnv + resetModules) Made-with: Cursor
This commit is contained in:
@@ -3,4 +3,6 @@ export type AuthFrequencyLimitProps = {
|
||||
maxAmount: number;
|
||||
expiredTime: Date;
|
||||
num?: number;
|
||||
/** When true, Mongo errors reject instead of failing open (for login and other strict paths). */
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authFrequencyLimit } from '../system/frequencyLimit/utils';
|
||||
import { addSeconds } from 'date-fns';
|
||||
import { type NextApiResponse } from 'next';
|
||||
import { jsonRes } from '../response';
|
||||
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
|
||||
|
||||
// unit: times/s
|
||||
// how to use?
|
||||
@@ -12,12 +13,15 @@ export function useIPFrequencyLimit({
|
||||
id,
|
||||
seconds,
|
||||
limit,
|
||||
force = false
|
||||
force = false,
|
||||
failClosed = false
|
||||
}: {
|
||||
id: string;
|
||||
seconds: number;
|
||||
limit: number;
|
||||
force?: boolean;
|
||||
/** When true, Mongo errors reject with tooManyRequest instead of failing open. */
|
||||
failClosed?: boolean;
|
||||
}) {
|
||||
return async (req: ApiRequestProps, res: NextApiResponse) => {
|
||||
const ip = requestIp.getClientIp(req);
|
||||
@@ -28,12 +32,17 @@ export function useIPFrequencyLimit({
|
||||
await authFrequencyLimit({
|
||||
eventId: `ip-qps-limit-${id}-` + ip,
|
||||
maxAmount: limit,
|
||||
expiredTime: addSeconds(new Date(), seconds)
|
||||
expiredTime: addSeconds(new Date(), seconds),
|
||||
strict: failClosed
|
||||
});
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
const isLimitExceeded = err && typeof err === 'object' && 'amount' in err;
|
||||
jsonRes(res, {
|
||||
code: 429,
|
||||
error: `Too many request, request ${limit} times every ${seconds} seconds`
|
||||
error:
|
||||
failClosed && !isLimitExceeded
|
||||
? ERROR_ENUM.tooManyRequest
|
||||
: `Too many request, request ${limit} times every ${seconds} seconds`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ export const authFrequencyLimit = async ({
|
||||
eventId,
|
||||
maxAmount,
|
||||
expiredTime,
|
||||
num = 1
|
||||
num = 1,
|
||||
strict = false
|
||||
}: AuthFrequencyLimitProps) => {
|
||||
try {
|
||||
// 对应 eventId 的 account+1, 不存在的话,则创建一个
|
||||
@@ -33,5 +34,8 @@ export const authFrequencyLimit = async ({
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update auth frequency limit', { eventId, error });
|
||||
if (strict) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { addSeconds } from 'date-fns';
|
||||
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
|
||||
import { MongoFrequencyLimit } from '../frequencyLimit/schema';
|
||||
import { getLogger, LogCategories } from '../../logger';
|
||||
|
||||
const logger = getLogger(LogCategories.SYSTEM);
|
||||
|
||||
export type LoginLockoutScope = 'app-password' | 'admin-password';
|
||||
|
||||
export function normalizeLoginAccountKey(username: string): string {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function buildLoginFailureEventId(
|
||||
scope: LoginLockoutScope,
|
||||
username: string,
|
||||
ip: string
|
||||
): string {
|
||||
const key = normalizeLoginAccountKey(username);
|
||||
const safeIp = ip || 'unknown';
|
||||
return `login-fail:${scope}:${key}:${safeIp}`;
|
||||
}
|
||||
|
||||
export type LoginSecurityLog = {
|
||||
scope: LoginLockoutScope;
|
||||
result:
|
||||
| 'locked'
|
||||
| 'wrong_password'
|
||||
| 'auth_code_failed'
|
||||
| 'invalid_account'
|
||||
| 'success_precheck_failed';
|
||||
normalizedAccount: string;
|
||||
ip: string;
|
||||
failCount?: number;
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
export function logLoginSecurityEvent(payload: LoginSecurityLog) {
|
||||
logger.info('login_security', {
|
||||
...payload,
|
||||
userAgent: payload.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLoginFailureCount(eventId: string): Promise<number> {
|
||||
const doc = await MongoFrequencyLimit.findOne({
|
||||
eventId,
|
||||
expiredTime: { $gte: new Date() }
|
||||
}).lean();
|
||||
return doc?.amount ?? 0;
|
||||
}
|
||||
|
||||
export async function assertLoginNotLockedByFailures(params: {
|
||||
eventId: string;
|
||||
maxAttempts: number;
|
||||
scope: LoginLockoutScope;
|
||||
normalizedAccount: string;
|
||||
ip: string;
|
||||
userAgent?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const count = await getLoginFailureCount(params.eventId);
|
||||
if (count >= params.maxAttempts) {
|
||||
logLoginSecurityEvent({
|
||||
scope: params.scope,
|
||||
result: 'locked',
|
||||
normalizedAccount: params.normalizedAccount,
|
||||
ip: params.ip,
|
||||
failCount: count,
|
||||
userAgent: params.userAgent
|
||||
});
|
||||
throw ERROR_ENUM.tooManyRequest;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e === ERROR_ENUM.tooManyRequest) {
|
||||
throw e;
|
||||
}
|
||||
logger.error('assertLoginNotLockedByFailures failed', {
|
||||
eventId: params.eventId,
|
||||
error: e
|
||||
});
|
||||
throw ERROR_ENUM.tooManyRequest;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordLoginFailure(params: {
|
||||
eventId: string;
|
||||
windowSeconds: number;
|
||||
}): Promise<number> {
|
||||
const { eventId, windowSeconds } = params;
|
||||
const expiredTime = addSeconds(new Date(), windowSeconds);
|
||||
try {
|
||||
const result = await MongoFrequencyLimit.findOneAndUpdate(
|
||||
{
|
||||
eventId,
|
||||
expiredTime: { $gte: new Date() }
|
||||
},
|
||||
{
|
||||
$inc: { amount: 1 },
|
||||
$setOnInsert: { expiredTime }
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true
|
||||
}
|
||||
).lean();
|
||||
return result?.amount ?? 1;
|
||||
} catch (error) {
|
||||
logger.error('recordLoginFailure failed', { eventId, error });
|
||||
throw ERROR_ENUM.tooManyRequest;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLoginFailures(eventId: string): Promise<void> {
|
||||
try {
|
||||
await MongoFrequencyLimit.deleteMany({ eventId });
|
||||
} catch (error) {
|
||||
logger.error('clearLoginFailures failed', { eventId, error });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 服务包环境变量单一来源:在 `server` schema 中声明,通过导出的 `env` 读取。
|
||||
* 勿在 `type/env.ts` 中扩充 `ProcessEnv`。
|
||||
*/
|
||||
import { createEnv } from '@t3-oss/env-core';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -6,6 +10,8 @@ const BoolSchema = z
|
||||
.transform((val) => val === 'true')
|
||||
.pipe(z.boolean());
|
||||
const NumSchema = z.coerce.number();
|
||||
const IntSchema = NumSchema.int();
|
||||
const PositiveIntSchema = IntSchema.positive();
|
||||
|
||||
const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']);
|
||||
|
||||
@@ -60,6 +66,10 @@ export const env = createEnv({
|
||||
// ===== Security =====
|
||||
CHECK_INTERNAL_IP: BoolSchema.default(false).meta({ description: '是否启用内网 IP 检查' }),
|
||||
|
||||
PASSWORD_LOGIN_LOCK_SECONDS: PositiveIntSchema.optional().default(120),
|
||||
LOGIN_FAIL_MAX_ATTEMPTS: PositiveIntSchema.optional().default(10),
|
||||
LOGIN_FAIL_WINDOW_SECONDS: PositiveIntSchema.optional(),
|
||||
|
||||
// Beta features
|
||||
// Whether the Skill feature is enabled (frontend entries + backend runtime)
|
||||
SHOW_SKILL: BoolSchema.default(false)
|
||||
|
||||
@@ -1,57 +1,9 @@
|
||||
/**
|
||||
* 不再在此维护 `NodeJS.ProcessEnv`:服务侧环境变量请以 `@fastgpt/service/env`(`env.ts` 内 zod `createEnv`)为准。
|
||||
* 此处仅保留与 `process.env` 类型无关的全局声明。
|
||||
*/
|
||||
declare global {
|
||||
var countTrackQueue: Map<string, { event: string; count: number; data: Record<string, any> }>;
|
||||
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
DEFAULT_ROOT_PSW: string;
|
||||
PRO_URL: string;
|
||||
LOG_DEPTH: string;
|
||||
DB_MAX_LINK: string;
|
||||
FILE_TOKEN_KEY: string;
|
||||
AES256_SECRET_KEY: string;
|
||||
ROOT_KEY: string;
|
||||
OPENAI_BASE_URL: string;
|
||||
CHAT_API_KEY: string;
|
||||
AIPROXY_API_ENDPOINT: string;
|
||||
AIPROXY_API_TOKEN: string;
|
||||
MULTIPLE_DATA_TO_BASE64: string;
|
||||
MONGODB_URI: string;
|
||||
MONGODB_LOG_URI?: string;
|
||||
|
||||
// Vector
|
||||
VECTOR_VQ_LEVEL: string;
|
||||
PG_URL: string;
|
||||
OPENGAUSS_URL: string;
|
||||
OCEANBASE_URL: string;
|
||||
SEEKDB_URL: string;
|
||||
MILVUS_ADDRESS: string;
|
||||
MILVUS_TOKEN: string;
|
||||
|
||||
CODE_SANDBOX_URL: string;
|
||||
FE_DOMAIN: string;
|
||||
FILE_DOMAIN: string;
|
||||
USE_IP_LIMIT?: string;
|
||||
WORKFLOW_MAX_RUN_TIMES?: string;
|
||||
WORKFLOW_MAX_LOOP_TIMES?: string;
|
||||
CHECK_INTERNAL_IP?: string;
|
||||
ALLOWED_ORIGINS?: string;
|
||||
SHOW_COUPON?: string;
|
||||
SHOW_DISCOUNT_COUPON?: string;
|
||||
CONFIG_JSON_PATH?: string;
|
||||
PASSWORD_LOGIN_LOCK_SECONDS?: string; // 密码登录锁定时间
|
||||
PASSWORD_EXPIRED_MONTH?: string;
|
||||
MAX_LOGIN_SESSION?: string;
|
||||
CHAT_MAX_QPM?: string;
|
||||
|
||||
CHAT_LOG_URL?: string;
|
||||
CHAT_LOG_INTERVAL?: string;
|
||||
CHAT_LOG_SOURCE_ID_PREFIX?: string;
|
||||
|
||||
NEXT_PUBLIC_BASE_URL: string;
|
||||
|
||||
MAX_HTML_TRANSFORM_CHARS: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user