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 {};
@@ -19,36 +19,98 @@ import {
type LoginSuccessResponseType
} from '@fastgpt/global/openapi/support/user/account/login/api';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { env } from '@fastgpt/service/env';
import {
assertLoginNotLockedByFailures,
buildLoginFailureEventId,
clearLoginFailures,
logLoginSecurityEvent,
normalizeLoginAccountKey,
recordLoginFailure
} from '@fastgpt/service/common/system/loginLockout/utils';
async function handler(
req: ApiRequestProps<LoginByPasswordBodyType>,
res: ApiResponseType
): Promise<LoginSuccessResponseType> {
const { username, password, code, language } = LoginByPasswordBodySchema.parse(req.body);
const clientIp = requestIp.getClientIp(req) || 'unknown';
const userAgent =
typeof req.headers?.['user-agent'] === 'string' ? req.headers['user-agent'] : undefined;
const maxAttempts = env.LOGIN_FAIL_MAX_ATTEMPTS;
const windowSeconds = env.LOGIN_FAIL_WINDOW_SECONDS ?? env.PASSWORD_LOGIN_LOCK_SECONDS;
const normalizedAccount = normalizeLoginAccountKey(username);
const failEventId = buildLoginFailureEventId('app-password', username, clientIp);
// Auth prelogin code
await authCode({
key: username,
code,
type: UserAuthTypeEnum.login
await assertLoginNotLockedByFailures({
eventId: failEventId,
maxAttempts,
scope: 'app-password',
normalizedAccount,
ip: clientIp,
userAgent
});
try {
await authCode({
key: username,
code,
type: UserAuthTypeEnum.login
});
} catch (e) {
const failCount = await recordLoginFailure({ eventId: failEventId, windowSeconds });
logLoginSecurityEvent({
scope: 'app-password',
result: 'auth_code_failed',
normalizedAccount,
ip: clientIp,
failCount,
userAgent
});
throw e;
}
const user = await MongoUser.findOne({
username,
password
});
if (!user) {
const failCount = await recordLoginFailure({ eventId: failEventId, windowSeconds });
logLoginSecurityEvent({
scope: 'app-password',
result: 'wrong_password',
normalizedAccount,
ip: clientIp,
failCount,
userAgent
});
return Promise.reject(UserErrEnum.account_psw_error);
}
if (user.status === UserStatusEnum.forbidden) {
const failCount = await recordLoginFailure({ eventId: failEventId, windowSeconds });
logLoginSecurityEvent({
scope: 'app-password',
result: 'invalid_account',
normalizedAccount,
ip: clientIp,
failCount,
userAgent
});
return Promise.reject('Invalid account!');
}
if (user) {
if (user.username.startsWith('wecom-')) {
return Promise.reject(new UserError('Wecom user can not login with password'));
}
if (user.username.startsWith('wecom-')) {
const failCount = await recordLoginFailure({ eventId: failEventId, windowSeconds });
logLoginSecurityEvent({
scope: 'app-password',
result: 'invalid_account',
normalizedAccount,
ip: clientIp,
failCount,
userAgent
});
return Promise.reject(new UserError('Wecom user can not login with password'));
}
const userDetail = await getUserDetail({
@@ -56,6 +118,8 @@ async function handler(
userId: user._id
});
await clearLoginFailures(failEventId);
user.lastLoginTmbId = userDetail.team.tmbId;
user.language = language;
await user.save();
@@ -88,8 +152,14 @@ async function handler(
};
}
const lockTime = Number(process.env.PASSWORD_LOGIN_LOCK_SECONDS || 120);
const lockTime = env.PASSWORD_LOGIN_LOCK_SECONDS;
export default NextAPI(
useIPFrequencyLimit({ id: 'login-by-password', seconds: lockTime, limit: 10, force: true }),
useIPFrequencyLimit({
id: 'login-by-password',
seconds: lockTime,
limit: 10,
force: true,
failClosed: true
}),
handler
);
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as loginApi from '@/pages/api/support/user/account/loginByPassword';
import { MongoUser } from '@fastgpt/service/support/user/schema';
import { UserStatusEnum } from '@fastgpt/global/support/user/constant';
@@ -9,6 +9,7 @@ import { setCookie } from '@fastgpt/service/support/permission/auth/common';
import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import { addAuditLog } from '@fastgpt/service/support/user/audit/util';
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { Call } from '@test/utils/request';
import type { LoginByPasswordBodyType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils';
@@ -231,6 +232,114 @@ describe('loginByPassword API', () => {
expect(typeof res.data.token).toBe('string');
});
describe('account + IP login lockout', () => {
async function loadLoginApi(maxAttempts: string) {
vi.unstubAllEnvs();
vi.stubEnv('LOGIN_FAIL_MAX_ATTEMPTS', maxAttempts);
vi.resetModules();
return import('@/pages/api/support/user/account/loginByPassword');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('should reject with tooManyRequest after max failed password attempts', async () => {
const loginMod = await loadLoginApi('3');
for (let i = 0; i < 3; i++) {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'wrongpassword',
code: '123456',
language: 'zh-CN'
}
});
expect(res.code).toBe(500);
expect(res.error).toBe(UserErrEnum.account_psw_error);
}
const locked = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'wrongpassword',
code: '123456',
language: 'zh-CN'
}
});
expect(locked.code).toBe(500);
expect(locked.error).toBe(ERROR_ENUM.tooManyRequest);
});
it('should clear failure counter after successful login', async () => {
const loginMod = await loadLoginApi('3');
for (let i = 0; i < 2; i++) {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'wrongpassword',
code: '123456',
language: 'zh-CN'
}
});
expect(res.code).toBe(500);
expect(res.error).toBe(UserErrEnum.account_psw_error);
}
const ok = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456',
language: 'zh-CN'
}
});
expect(ok.code).toBe(200);
const after = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'wrongpassword',
code: '123456',
language: 'zh-CN'
}
});
expect(after.code).toBe(500);
expect(after.error).toBe(UserErrEnum.account_psw_error);
});
it('should count auth code failures toward lockout', async () => {
vi.mocked(authCode).mockRejectedValue(new Error('Invalid code'));
const loginMod = await loadLoginApi('2');
for (let i = 0; i < 2; i++) {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: 'wrongcode',
language: 'zh-CN'
}
});
expect(res.code).toBe(500);
}
const locked = await Call<LoginByPasswordBodyType, {}, any>(loginMod.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: 'wrongcode',
language: 'zh-CN'
}
});
expect(locked.code).toBe(500);
expect(locked.error).toBe(ERROR_ENUM.tooManyRequest);
});
});
// ===== Security: NoSQL injection prevention (GHSA-jxvr-h2vx-p73r) =====
describe('NoSQL injection prevention', () => {