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:
xqvvu
2026-04-15 14:13:00 +08:00
parent b35288fe5b
commit 1cc412e1d0
8 changed files with 345 additions and 69 deletions
@@ -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 });
}
}
+10
View File
@@ -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)
+4 -52
View File
@@ -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 {};