feat: session id (#4817)

* feat: session id

* feat: Add default index
This commit is contained in:
Archer
2025-05-14 17:24:02 +08:00
committed by GitHub
parent cba8f773fe
commit a4db03a3b7
10 changed files with 248 additions and 75 deletions

View File

@@ -0,0 +1,21 @@
---
title: 'V4.9.9(进行中)'
description: 'FastGPT V4.9.9 更新说明'
icon: 'upgrade'
draft: false
toc: true
weight: 791
---
## 🚀 新增内容
1. 切换 SessionId 来替代 JWT 实现登录鉴权,可控制最大登录客户端数量。
## ⚙️ 优化
## 🐛 修复

2
env.d.ts vendored
View File

@@ -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;
}
}
}

View File

@@ -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)));
};

View File

@@ -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;
};

View File

@@ -1,5 +1,5 @@
import type Redis from 'ioredis';
declare global {
var redisCache: Redis | null;
var redisClient: Redis | null;
}

View File

@@ -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 工具

View File

@@ -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,

View File

@@ -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<SessionType> => {
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<SessionType> => {
const data = await getSession(key);
return data;
};

View File

@@ -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=
# 特殊配置
# 自定义跨域,不配置时,默认都允许跨域(逗号分割)

View File

@@ -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,