Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/service/common/middle/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyCsrfToken } from '../../support/permission/auth/common';
import { generateCsrfToken } from '../../../../projects/app/src/web/support/user/api';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyCsrfToken 和 generateCsrfToken 应该放在一个模块里面

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

前端检测到cookie即将过期, 要主动请求generateCsrfToken, 所以generateCsrfToken放在了api模块, 便于请求; verifyCsrfToken则放在中间件模块用于验证CsrfToken


export const withCSRFCheck = async (
req: NextApiRequest,
res: NextApiResponse,
isCSRFCheck: boolean = true
) => {
if (!isCSRFCheck) return;

try {
const csrfToken = await getCsrfTokenFromRequest(req);
verifyCsrfToken(csrfToken);
} catch (error) {
return res.status(403).json({
code: 403,
message: 'Invalid CSRF token'
});
}
};

async function getCsrfTokenFromRequest(req: NextApiRequest): Promise<string | null> {
const headerToken = req.headers['x-csrf-token'];

if (!headerToken || typeof headerToken !== 'string') {
const { csrfToken } = await generateCsrfToken();
return csrfToken;
}

return headerToken;
}
19 changes: 17 additions & 2 deletions packages/service/common/middle/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,46 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { withNextCors } from './cors';
import { type ApiRequestProps } from '../../type/next';
import { addLog } from '../system/log';
import { withCSRFCheck } from './csrf';

export type NextApiHandler<T = any> = (
req: ApiRequestProps,
res: NextApiResponse<T>
) => unknown | Promise<unknown>;

type NextAPIOptsType = {
isCSRFCheck: boolean;
};
type Args = [...NextApiHandler[], NextAPIOptsType] | NextApiHandler[];

export const NextEntry = ({
beforeCallback = []
}: {
beforeCallback?: ((req: NextApiRequest, res: NextApiResponse) => Promise<any>)[];
}) => {
return (...args: NextApiHandler[]): NextApiHandler => {
return (...args: Args): NextApiHandler => {
const opts = (() => {
if (typeof args.at(-1) === 'function') {
return {
isCSRFCheck: true
} as NextAPIOptsType;
}
return args.at(-1) as NextAPIOptsType;
})();
return async function api(req: ApiRequestProps, res: NextApiResponse) {
const start = Date.now();
addLog.debug(`Request start ${req.url}`);

try {
await Promise.all([
withNextCors(req, res),
withCSRFCheck(req, res, opts.isCSRFCheck),
...beforeCallback.map((item) => item(req, res))
]);

let response = null;
for await (const handler of args) {
response = await handler(req, res);
if (typeof handler === 'function') response = await handler(req, res);
if (res.writableFinished) {
break;
}
Expand Down
37 changes: 37 additions & 0 deletions packages/service/support/permission/auth/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { authUserSession } from '../../../support/user/session';
import { authOpenApiKey } from '../../../support/openapi/auth';
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
import jwt from 'jsonwebtoken';

export const authCert = async (props: AuthModeType) => {
const result = await parseHeaderCert(props);
Expand Down Expand Up @@ -171,3 +172,39 @@ export const setCookie = (res: NextApiResponse, token: string) => {
export const clearCookie = (res: NextApiResponse) => {
res.setHeader('Set-Cookie', `${TokenName}=; Path=/; Max-Age=0`);
};

/* CSRF token */
export const CsrfTokenName = 'csrf_token';

/* set CSRF cookie */
export const setCsrfCookie = (res: NextApiResponse, token: string) => {
res.setHeader(
'Set-Cookie',
`${CsrfTokenName}=${token}; Path=/; HttpOnly; Max-Age=3600; Samesite=Strict;`
);
};

/* clear CSRF cookie */
export const clearCsrfCookie = (res: NextApiResponse) => {
res.setHeader('Set-Cookie', `${CsrfTokenName}=; Path=/; Max-Age=0`);
};

/* verify CSRF JWT token */
export const verifyCsrfToken = (token: string | null): any => {
if (!token) {
throw new Error('CSRF token is required');
}
try {
const jwtSecret = process.env.TOKEN_KEY || 'any';
const decoded = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });

if (typeof decoded === 'object' && decoded.type === 'csrf') {
return decoded;
}

throw new Error('Invalid CSRF token type');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`CSRF token verification failed: ${errorMessage}`);
}
};
3 changes: 1 addition & 2 deletions projects/app/src/components/Markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,7 @@ const MarkdownRender = ({
'base',
'form',
'input',
'button',
'img'
'button'
]
}
]
Expand Down
2 changes: 1 addition & 1 deletion projects/app/src/pages/api/common/file/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
removeFilesByPaths(filePaths);
}

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });

export const config = {
api: {
Expand Down
2 changes: 1 addition & 1 deletion projects/app/src/pages/api/common/file/uploadImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse): Promise<strin
return uploadMongoImg({ teamId, ...body });
}

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });

export const config = {
api: {
Expand Down
3 changes: 2 additions & 1 deletion projects/app/src/pages/api/core/app/exportChatLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,5 +417,6 @@ async function handler(req: ApiRequestProps<ExportChatLogsBody, {}>, res: NextAp

export default NextAPI(
useIPFrequencyLimit({ id: 'export-chat-logs', seconds: 60, limit: 1, force: true }),
handler
handler,
{ isCSRFCheck: false }
);
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ async function handler(
};
}

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async function handler(
}
}

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });

export const config = {
api: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ export const config = {
}
};

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });
3 changes: 2 additions & 1 deletion projects/app/src/pages/api/core/dataset/collection/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,6 @@ async function handler(req: ApiRequestProps<ExportCollectionBody, {}>, res: Next

export default NextAPI(
useIPFrequencyLimit({ id: 'export-usage', seconds: 60, limit: 1, force: true }),
handler
handler,
{ isCSRFCheck: false }
);
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async function handler(
}
}

export default NextAPI(handler);
export default NextAPI(handler, { isCSRFCheck: false });

export const config = {
api: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { authCert, setCsrfCookie } from '@fastgpt/service/support/permission/auth/common';
import jwt from 'jsonwebtoken';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';

export type GenerateCsrfTokenQuery = {};
export type GenerateCsrfTokenBody = {};
export type GenerateCsrfTokenResponse = {
csrfToken: string;
expiresAt: number;
};

async function handler(
req: ApiRequestProps<GenerateCsrfTokenBody, GenerateCsrfTokenQuery>,
res: ApiResponseType<GenerateCsrfTokenResponse>
): Promise<GenerateCsrfTokenResponse> {
const jwtSecret = process.env.TOKEN_KEY || 'any';
const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60;
const csrfToken = jwt.sign(
{
type: 'csrf',
exp: expiresAt
},
jwtSecret,
{
algorithm: 'HS256'
}
);

setCsrfCookie(res, csrfToken);

return {
csrfToken,
expiresAt
};
}

export default NextAPI(handler, { isCSRFCheck: false });
2 changes: 2 additions & 0 deletions projects/app/src/pages/api/support/user/account/loginout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { authCert, clearCookie } from '@fastgpt/service/support/permission/auth/common';
import { delUserAllSession } from '@fastgpt/service/support/user/session';
import { clearCsrfCookie } from '@fastgpt/service/support/permission/auth/common';

async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { userId } = await authCert({ req, authToken: true });
await delUserAllSession(userId);
} catch (error) {}
clearCookie(res);
clearCsrfCookie(res);
}

export default NextAPI(handler);
5 changes: 4 additions & 1 deletion projects/app/src/web/common/api/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSystemStore } from '../system/useSystemStore';
import { formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import type { OnOptimizePromptProps } from '@/components/common/PromptEditor/OptimizerPopover';
import { getCsrfToken } from '../utils/csrfToken';

type StreamFetchProps = {
url?: string;
Expand Down Expand Up @@ -121,11 +122,13 @@ export const streamFetch = ({
// auto complete variables
const variables = data?.variables || {};
variables.cTime = formatTime2YMDHMW();
const csrfToken = await getCsrfToken();

const requestData = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
signal: abortCtrl.signal,
body: JSON.stringify({
Expand Down
14 changes: 13 additions & 1 deletion projects/app/src/web/common/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSystemStore } from '../system/useSystemStore';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { i18nT } from '@fastgpt/web/i18n/utils';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { getCsrfToken } from '../utils/csrfToken';

interface ConfigType {
headers?: { [key: string]: string };
Expand Down Expand Up @@ -78,8 +79,19 @@ function requestFinish({ signId, url }: { signId?: string; url: string }) {
/**
* 请求开始
*/
function startInterceptors(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
async function startInterceptors(
config: InternalAxiosRequestConfig
): Promise<InternalAxiosRequestConfig> {
if (config.headers) {
// prevent circular requests
const isGenerateCsrfTokenRequest = config.url?.includes(
'/support/user/account/generateCsrfToken'
);
const csrfToken = isGenerateCsrfTokenRequest ? '' : await getCsrfToken();

if (csrfToken) {
config.headers['x-csrf-token'] = csrfToken;
}
}

return config;
Expand Down
56 changes: 56 additions & 0 deletions projects/app/src/web/common/utils/csrfToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { generateCsrfToken } from '@/web/support/user/api';

const CSRF_TOKEN_STORAGE_KEY = 'csrf_token';
const CSRF_EXPIRES_STORAGE_KEY = 'csrf_expires';

interface CsrfTokenData {
token: string;
expiresAt: number;
}

export const getCsrfToken = async (): Promise<string> => {
const storedToken = getStoredToken();

if (storedToken && isTokenValid(storedToken.expiresAt)) {
return storedToken.token;
}

return fetchNewToken();
};

const getStoredToken = (): CsrfTokenData | null => {
const token = localStorage.getItem(CSRF_TOKEN_STORAGE_KEY);
const expiresAt = localStorage.getItem(CSRF_EXPIRES_STORAGE_KEY);

if (token && expiresAt) {
return {
token,
expiresAt: parseInt(expiresAt, 10)
};
}

return null;
};

const isTokenValid = (expiresAt: number): boolean => {
const currentTime = Math.floor(Date.now() / 1000);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

怎么还 /1000,直接比较 timestamp 不就行了吗

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jwt标准是用秒级时间戳来生成签名, 这里 /1000 是为与jwt的秒级别统一

const bufferTime = 10 * 60;

return expiresAt > currentTime + bufferTime;
};

const fetchNewToken = async (): Promise<string> => {
const csrfTokenData = await generateCsrfToken();

if (csrfTokenData.csrfToken && csrfTokenData.expiresAt) {
localStorage.setItem(CSRF_TOKEN_STORAGE_KEY, csrfTokenData.csrfToken);
localStorage.setItem(CSRF_EXPIRES_STORAGE_KEY, csrfTokenData.expiresAt.toString());
return csrfTokenData.csrfToken;
}
return '';
};

export const clearCsrfToken = (): void => {
localStorage.removeItem(CSRF_TOKEN_STORAGE_KEY);
localStorage.removeItem(CSRF_EXPIRES_STORAGE_KEY);
};
3 changes: 3 additions & 0 deletions projects/app/src/web/support/user/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,6 @@ export const GetSearchUserGroupOrg = (
GET<SearchResult>('/proApi/support/user/search', { searchKey, ...options }, { maxQuantity: 1 });

export const ExportMembers = () => GET<{ csv: string }>('/proApi/support/user/team/member/export');

export const generateCsrfToken = () =>
GET<{ csrfToken: string; expiresAt: number }>('/support/user/account/generateCsrfToken');
5 changes: 4 additions & 1 deletion projects/app/src/web/support/user/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { loginOut } from '@/web/support/user/api';
import { clearCsrfToken } from '@/web/common/utils/csrfToken';

export const clearToken = () => {
export const clearToken = async () => {
try {
clearCsrfToken();
return loginOut();
} catch (error) {
clearCsrfToken();
error;
}
};
Loading