diff --git a/packages/global/common/frequenctLimit/type.ts b/packages/global/common/frequenctLimit/type.ts index fb5bf2105d..d1221d30f7 100644 --- a/packages/global/common/frequenctLimit/type.ts +++ b/packages/global/common/frequenctLimit/type.ts @@ -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; }; diff --git a/packages/service/common/middle/reqFrequencyLimit.ts b/packages/service/common/middle/reqFrequencyLimit.ts index 7912825bcc..d38ff120c4 100644 --- a/packages/service/common/middle/reqFrequencyLimit.ts +++ b/packages/service/common/middle/reqFrequencyLimit.ts @@ -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` }); } }; diff --git a/packages/service/common/system/frequencyLimit/utils.ts b/packages/service/common/system/frequencyLimit/utils.ts index 2046bc9201..5e5c3dead3 100644 --- a/packages/service/common/system/frequencyLimit/utils.ts +++ b/packages/service/common/system/frequencyLimit/utils.ts @@ -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; + } } }; diff --git a/packages/service/common/system/loginLockout/utils.ts b/packages/service/common/system/loginLockout/utils.ts new file mode 100644 index 0000000000..d0dd1a9638 --- /dev/null +++ b/packages/service/common/system/loginLockout/utils.ts @@ -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 { + 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 { + 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 { + 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 { + try { + await MongoFrequencyLimit.deleteMany({ eventId }); + } catch (error) { + logger.error('clearLoginFailures failed', { eventId, error }); + } +} diff --git a/packages/service/env.ts b/packages/service/env.ts index b3a5591b81..c39dc11087 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -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) diff --git a/packages/service/type/env.ts b/packages/service/type/env.ts index 6077902302..7c43adcb0d 100644 --- a/packages/service/type/env.ts +++ b/packages/service/type/env.ts @@ -1,57 +1,9 @@ +/** + * 不再在此维护 `NodeJS.ProcessEnv`:服务侧环境变量请以 `@fastgpt/service/env`(`env.ts` 内 zod `createEnv`)为准。 + * 此处仅保留与 `process.env` 类型无关的全局声明。 + */ declare global { var countTrackQueue: Map }>; - - 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 {}; diff --git a/projects/app/src/pages/api/support/user/account/loginByPassword.ts b/projects/app/src/pages/api/support/user/account/loginByPassword.ts index 5e8c91839d..ff90c74001 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -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, res: ApiResponseType ): Promise { 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 ); diff --git a/projects/app/test/api/support/user/account/loginByPassword.test.ts b/projects/app/test/api/support/user/account/loginByPassword.test.ts index db55c3409c..85bcb61f30 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -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(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(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(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(loginMod.default, { + body: { + username: 'testuser', + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); + expect(ok.code).toBe(200); + + const after = await Call(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(loginMod.default, { + body: { + username: 'testuser', + password: 'testpassword', + code: 'wrongcode', + language: 'zh-CN' + } + }); + expect(res.code).toBe(500); + } + + const locked = await Call(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', () => {