From a4db03a3b7c428c97571fe47290af7876f8b101e Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 14 May 2025 17:24:02 +0800 Subject: [PATCH] feat: session id (#4817) * feat: session id * feat: Add default index --- .../zh-cn/docs/development/upgrading/499.md | 21 ++ env.d.ts | 2 +- packages/service/common/redis/cache.ts | 19 +- packages/service/common/redis/index.ts | 21 +- packages/service/common/redis/type.d.ts | 2 +- packages/service/core/ai/utils.ts | 4 +- .../service/support/permission/controller.ts | 49 +---- packages/service/support/user/session.ts | 179 ++++++++++++++++++ projects/app/.env.template | 4 +- .../support/user/account/loginByPassword.ts | 22 ++- 10 files changed, 248 insertions(+), 75 deletions(-) create mode 100644 docSite/content/zh-cn/docs/development/upgrading/499.md create mode 100644 packages/service/support/user/session.ts diff --git a/docSite/content/zh-cn/docs/development/upgrading/499.md b/docSite/content/zh-cn/docs/development/upgrading/499.md new file mode 100644 index 000000000..e19f29f62 --- /dev/null +++ b/docSite/content/zh-cn/docs/development/upgrading/499.md @@ -0,0 +1,21 @@ +--- +title: 'V4.9.9(进行中)' +description: 'FastGPT V4.9.9 更新说明' +icon: 'upgrade' +draft: false +toc: true +weight: 791 +--- + + +## 🚀 新增内容 + +1. 切换 SessionId 来替代 JWT 实现登录鉴权,可控制最大登录客户端数量。 + +## ⚙️ 优化 + + + +## 🐛 修复 + + diff --git a/env.d.ts b/env.d.ts index 9bc671877..293b1e0c7 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,7 +4,6 @@ declare global { LOG_DEPTH: string; DEFAULT_ROOT_PSW: string; DB_MAX_LINK: string; - TOKEN_KEY: string; FILE_TOKEN_KEY: string; ROOT_KEY: string; OPENAI_BASE_URL: string; @@ -37,6 +36,7 @@ declare global { CONFIG_JSON_PATH?: string; PASSWORD_LOGIN_LOCK_SECONDS?: string; PASSWORD_EXPIRED_MONTH?: string; + MAX_LOGIN_SESSION?: string; } } } diff --git a/packages/service/common/redis/cache.ts b/packages/service/common/redis/cache.ts index d27f5b37c..0ab3baabc 100644 --- a/packages/service/common/redis/cache.ts +++ b/packages/service/common/redis/cache.ts @@ -1,7 +1,10 @@ -import { getGlobalRedisCacheConnection } from './index'; +import { getGlobalRedisConnection } from './index'; import { addLog } from '../system/log'; import { retryFn } from '@fastgpt/global/common/system/utils'; +const redisPrefix = 'cache:'; +const getCacheKey = (key: string) => `${redisPrefix}${key}`; + export enum CacheKeyEnum { team_vector_count = 'team_vector_count' } @@ -13,12 +16,12 @@ export const setRedisCache = async ( ) => { return await retryFn(async () => { try { - const redis = getGlobalRedisCacheConnection(); + const redis = getGlobalRedisConnection(); if (expireSeconds) { - await redis.set(key, data, 'EX', expireSeconds); + await redis.set(getCacheKey(key), data, 'EX', expireSeconds); } else { - await redis.set(key, data); + await redis.set(getCacheKey(key), data); } } catch (error) { addLog.error('Set cache error:', error); @@ -28,11 +31,11 @@ export const setRedisCache = async ( }; export const getRedisCache = async (key: string) => { - const redis = getGlobalRedisCacheConnection(); - return await retryFn(() => redis.get(key)); + const redis = getGlobalRedisConnection(); + return await retryFn(() => redis.get(getCacheKey(key))); }; export const delRedisCache = async (key: string) => { - const redis = getGlobalRedisCacheConnection(); - await retryFn(() => redis.del(key)); + const redis = getGlobalRedisConnection(); + await retryFn(() => redis.del(getCacheKey(key))); }; diff --git a/packages/service/common/redis/index.ts b/packages/service/common/redis/index.ts index 1ec8a159c..55f8126ba 100644 --- a/packages/service/common/redis/index.ts +++ b/packages/service/common/redis/index.ts @@ -27,17 +27,26 @@ export const newWorkerRedisConnection = () => { return redis; }; -export const getGlobalRedisCacheConnection = () => { - if (global.redisCache) return global.redisCache; +export const FASTGPT_REDIS_PREFIX = 'fastgpt:'; +export const getGlobalRedisConnection = () => { + if (global.redisClient) return global.redisClient; - global.redisCache = new Redis(REDIS_URL, { keyPrefix: 'fastgpt:cache:' }); + global.redisClient = new Redis(REDIS_URL, { keyPrefix: FASTGPT_REDIS_PREFIX }); - global.redisCache.on('connect', () => { + global.redisClient.on('connect', () => { addLog.info('Redis connected'); }); - global.redisCache.on('error', (error) => { + global.redisClient.on('error', (error) => { addLog.error('Redis connection error', error); }); - return global.redisCache; + return global.redisClient; +}; + +export const getAllKeysByPrefix = async (key: string) => { + const redis = getGlobalRedisConnection(); + const keys = (await redis.keys(`${FASTGPT_REDIS_PREFIX}${key}:*`)).map((key) => + key.replace(FASTGPT_REDIS_PREFIX, '') + ); + return keys; }; diff --git a/packages/service/common/redis/type.d.ts b/packages/service/common/redis/type.d.ts index 0aa59e147..03e3ecbb1 100644 --- a/packages/service/common/redis/type.d.ts +++ b/packages/service/common/redis/type.d.ts @@ -1,5 +1,5 @@ import type Redis from 'ioredis'; declare global { - var redisCache: Redis | null; + var redisClient: Redis | null; } diff --git a/packages/service/core/ai/utils.ts b/packages/service/core/ai/utils.ts index 99b73753c..449160ea8 100644 --- a/packages/service/core/ai/utils.ts +++ b/packages/service/core/ai/utils.ts @@ -135,8 +135,8 @@ export const llmStreamResponseToAnswerText = async ( // Tool calls if (responseChoice?.tool_calls?.length) { - responseChoice.tool_calls.forEach((toolCall) => { - const index = toolCall.index; + responseChoice.tool_calls.forEach((toolCall, i) => { + const index = toolCall.index ?? i; if (toolCall.id || callingTool) { // 有 id,代表新 call 工具 diff --git a/packages/service/support/permission/controller.ts b/packages/service/support/permission/controller.ts index be7d1508b..88abacb83 100644 --- a/packages/service/support/permission/controller.ts +++ b/packages/service/support/permission/controller.ts @@ -20,6 +20,7 @@ import { type MemberGroupSchemaType } from '@fastgpt/global/support/permission/m import { type TeamMemberSchema } from '@fastgpt/global/support/user/team/type'; import { type OrgSchemaType } from '@fastgpt/global/support/user/team/org/type'; import { getOrgIdSetWithParentByTmbId } from './org/controllers'; +import { authUserSession } from '../user/session'; /** get resource permission for a team member * If there is no permission for the team member, it will return undefined @@ -213,51 +214,6 @@ export const delResourcePermission = ({ }; /* 下面代码等迁移 */ -/* create token */ -export function createJWT(user: { - _id?: string; - team?: { teamId?: string; tmbId: string }; - isRoot?: boolean; -}) { - const key = process.env.TOKEN_KEY as string; - const token = jwt.sign( - { - userId: String(user._id), - teamId: String(user.team?.teamId), - tmbId: String(user.team?.tmbId), - isRoot: user.isRoot, - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 - }, - key - ); - return token; -} - -// auth token -export function authJWT(token: string) { - return new Promise<{ - userId: string; - teamId: string; - tmbId: string; - isRoot: boolean; - }>((resolve, reject) => { - const key = process.env.TOKEN_KEY as string; - - jwt.verify(token, key, (err, decoded: any) => { - if (err || !decoded?.userId) { - reject(ERROR_ENUM.unAuthorization); - return; - } - - resolve({ - userId: decoded.userId, - teamId: decoded.teamId || '', - tmbId: decoded.tmbId, - isRoot: decoded.isRoot - }); - }); - }); -} export async function parseHeaderCert({ req, @@ -275,7 +231,7 @@ export async function parseHeaderCert({ return Promise.reject(ERROR_ENUM.unAuthorization); } - return await authJWT(cookieToken); + return authUserSession(cookieToken); } // from authorization get apikey async function parseAuthorization(authorization?: string) { @@ -345,6 +301,7 @@ export async function parseHeaderCert({ if (authToken && (token || cookie)) { // user token(from fastgpt web) const res = await authCookieToken(cookie, token); + return { uid: res.userId, teamId: res.teamId, diff --git a/packages/service/support/user/session.ts b/packages/service/support/user/session.ts new file mode 100644 index 000000000..d30572560 --- /dev/null +++ b/packages/service/support/user/session.ts @@ -0,0 +1,179 @@ +import { retryFn } from '@fastgpt/global/common/system/utils'; +import { getAllKeysByPrefix, getGlobalRedisConnection } from '../../common/redis'; +import { addLog } from '../../common/system/log'; +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; + +const redisPrefix = 'session:'; +const getSessionKey = (key: string) => `${redisPrefix}${key}`; + +type SessionType = { + userId: string; + teamId: string; + tmbId: string; + isRoot?: boolean; + createdAt: number; + ip?: string | null; +}; + +/* Session manager */ +const setSession = async ({ + key, + data, + expireSeconds +}: { + key: string; + data: SessionType; + expireSeconds: number; +}) => { + return await retryFn(async () => { + try { + const redis = getGlobalRedisConnection(); + const formatKey = getSessionKey(key); + + // 使用 hmset 存储对象字段 + await redis.hmset(formatKey, { + userId: data.userId, + teamId: data.teamId, + tmbId: data.tmbId, + isRoot: data.isRoot ? '1' : '0', + createdAt: data.createdAt.toString(), + ip: data.ip + }); + + // 设置过期时间 + if (expireSeconds) { + await redis.expire(formatKey, expireSeconds); + } + } catch (error) { + addLog.error('Set session error:', error); + return Promise.reject(error); + } + }); +}; + +const delSession = (key: string) => { + const redis = getGlobalRedisConnection(); + retryFn(() => redis.del(getSessionKey(key))); +}; + +const getSession = async (key: string): Promise => { + const formatKey = getSessionKey(key); + const redis = getGlobalRedisConnection(); + + // 使用 hgetall 获取所有字段 + const data = await retryFn(() => redis.hgetall(formatKey)); + + if (!data || Object.keys(data).length === 0) { + return Promise.reject(ERROR_ENUM.unAuthorization); + } + + try { + return { + userId: data.userId, + teamId: data.teamId, + tmbId: data.tmbId, + isRoot: data.isRoot === '1', + createdAt: parseInt(data.createdAt), + ip: data.ip + }; + } catch (error) { + addLog.error('Parse session error:', error); + delSession(formatKey); + return Promise.reject(ERROR_ENUM.unAuthorization); + } +}; + +export const delUserAllSession = async (userId: string, whileList?: string[]) => { + const formatWhileList = whileList?.map((item) => getSessionKey(item)); + const redis = getGlobalRedisConnection(); + const keys = (await getAllKeysByPrefix(`${redisPrefix}${userId}`)).filter( + (item) => !formatWhileList?.includes(item) + ); + + if (keys.length > 0) { + await redis.del(keys); + } +}; + +// 会根据创建时间,删除超出客户端登录限制的 session +const delRedundantSession = async (userId: string) => { + // 至少为 1,默认为 10 + let maxSession = process.env.MAX_LOGIN_SESSION ? Number(process.env.MAX_LOGIN_SESSION) : 10; + if (maxSession < 1) { + maxSession = 1; + } + + const redis = getGlobalRedisConnection(); + const keys = await getAllKeysByPrefix(`${redisPrefix}${userId}`); + + if (keys.length <= maxSession) { + return; + } + + // 获取所有会话的创建时间 + const sessionList = await Promise.all( + keys.map(async (key) => { + try { + const data = await redis.hgetall(key); + if (!data || Object.keys(data).length === 0) return null; + + return { + key, + createdAt: parseInt(data.createdAt) + }; + } catch (error) { + return null; + } + }) + ); + + // 过滤掉无效数据并按创建时间排序 + const validSessions = sessionList.filter(Boolean) as { key: string; createdAt: number }[]; + + validSessions.sort((a, b) => a.createdAt - b.createdAt); + + // 删除最早创建的会话 + const delKeys = validSessions.slice(0, validSessions.length - maxSession).map((item) => item.key); + + if (delKeys.length > 0) { + await redis.del(delKeys); + } +}; +export const createUserSession = async ({ + userId, + teamId, + tmbId, + isRoot, + ip +}: { + userId: string; + teamId: string; + tmbId: string; + isRoot?: boolean; + ip?: string | null; +}) => { + const key = `${String(userId)}:${getNanoid(32)}`; + + await setSession({ + key, + data: { + userId: String(userId), + teamId: String(teamId), + tmbId: String(tmbId), + isRoot, + createdAt: new Date().getTime(), + ip + }, + expireSeconds: 7 * 24 * 60 * 60 + }); + + delRedundantSession(userId); + + return key; +}; + +export const authUserSession = async (key: string): Promise => { + const data = await getSession(key); + return data; +}; diff --git a/projects/app/.env.template b/projects/app/.env.template index d3eb6d6dc..b577c1ad9 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -3,8 +3,6 @@ LOG_DEPTH=3 DEFAULT_ROOT_PSW=123456 # 数据库最大连接数 DB_MAX_LINK=5 -# token -TOKEN_KEY=dfdasfdas # 文件阅读时的密钥 FILE_TOKEN_KEY=filetokenkey # root key, 最高权限 @@ -65,6 +63,8 @@ CHECK_INTERNAL_IP=false PASSWORD_LOGIN_LOCK_SECONDS= # 密码过期月份,不设置则不会过期 PASSWORD_EXPIRED_MONTH= +# 最大登录客户端数量,默认为 10 +MAX_LOGIN_SESSION= # 特殊配置 # 自定义跨域,不配置时,默认都允许跨域(逗号分割) 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 97fb338e4..62e927a5a 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { MongoUser } from '@fastgpt/service/support/user/schema'; -import { createJWT, setCookie } from '@fastgpt/service/support/permission/controller'; +import { setCookie } from '@fastgpt/service/support/permission/controller'; import { getUserDetail } from '@fastgpt/service/support/user/controller'; import type { PostLoginProps } from '@fastgpt/global/support/user/api.d'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; @@ -13,6 +13,8 @@ import { addOperationLog } from '@fastgpt/service/support/operationLog/addOperat import { OperationLogEventEnum } from '@fastgpt/global/support/operationLog/constants'; import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { authCode } from '@fastgpt/service/support/user/auth/controller'; +import { createUserSession } from '@fastgpt/service/support/user/session'; +import requestIp from 'request-ip'; async function handler(req: NextApiRequest, res: NextApiResponse) { const { username, password, code } = req.body as PostLoginProps; @@ -61,20 +63,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { lastLoginTmbId: userDetail.team.tmbId }); + const token = await createUserSession({ + userId: user._id, + teamId: userDetail.team.teamId, + tmbId: userDetail.team.tmbId, + isRoot: username === 'root', + ip: requestIp.getClientIp(req) + }); + + setCookie(res, token); + pushTrack.login({ type: 'password', uid: user._id, teamId: userDetail.team.teamId, tmbId: userDetail.team.tmbId }); - - const token = createJWT({ - ...userDetail, - isRoot: username === 'root' - }); - - setCookie(res, token); - addOperationLog({ tmbId: userDetail.team.tmbId, teamId: userDetail.team.teamId,