mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-05 01:02:59 +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 {};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user