refactor: replace localStorage with HTTP-only cookies for auth tokens (#24365)

Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <wylswz@163.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: GareArc <chen4851@purdue.edu>
Co-authored-by: NFish <douxc512@gmail.com>
Co-authored-by: Davide Delbianco <davide.delbianco@outlook.com>
Co-authored-by: minglu7 <1347866672@qq.com>
Co-authored-by: Ponder <ruan.lj@foxmail.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: heyszt <270985384@qq.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Guangdong Liu <liugddx@gmail.com>
Co-authored-by: Eric Guo <eric.guocz@gmail.com>
Co-authored-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Co-authored-by: XlKsyt <caixuesen@outlook.com>
Co-authored-by: Dhruv Gorasiya <80987415+DhruvGorasiya@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
Co-authored-by: GuanMu <ballmanjq@gmail.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tonlo <123lzs123@gmail.com>
Co-authored-by: Yusuke Yamada <yamachu.dev@gmail.com>
Co-authored-by: Novice <novice12185727@gmail.com>
Co-authored-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: Ademílson Tonato <ademilsonft@outlook.com>
Co-authored-by: znn <jubinkumarsoni@gmail.com>
Co-authored-by: yangzheli <43645580+yangzheli@users.noreply.github.com>
This commit is contained in:
-LAN-
2025-10-19 21:29:04 +08:00
committed by GitHub
parent 141ca8904a
commit 9a5f214623
60 changed files with 879 additions and 533 deletions

View File

@@ -1,4 +1,4 @@
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
import { refreshAccessTokenOrRelogin } from './refresh-token'
import Toast from '@/app/components/base/toast'
import { basePath } from '@/utils/var'
@@ -21,15 +21,16 @@ import type {
WorkflowFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { removeAccessToken } from '@/app/components/share/utils'
import type { FetchOptionType, ResponseError } from './fetch'
import { ContentType, base, getAccessToken, getBaseOptions } from './fetch'
import { ContentType, base, getBaseOptions } from './fetch'
import { asyncRunSafe } from '@/utils'
import type {
DataSourceNodeCompletedResponse,
DataSourceNodeErrorResponse,
DataSourceNodeProcessingResponse,
} from '@/types/pipeline'
import Cookies from 'js-cookie'
import { getWebAppPassport } from './webapp-auth'
const TIME_OUT = 100000
export type IOnDataMoreInfo = {
@@ -122,14 +123,19 @@ function unicodeToChar(text: string) {
})
}
const WBB_APP_LOGIN_PATH = '/webapp-signin'
function requiredWebSSOLogin(message?: string, code?: number) {
const params = new URLSearchParams()
// prevent redirect loop
if(globalThis.location.pathname === WBB_APP_LOGIN_PATH)
return
params.append('redirect_url', encodeURIComponent(`${globalThis.location.pathname}${globalThis.location.search}`))
if (message)
params.append('message', message)
if (code)
params.append('code', String(code))
globalThis.location.href = `${globalThis.location.origin}${basePath}/webapp-signin?${params.toString()}`
globalThis.location.href = `${globalThis.location.origin}${basePath}/${WBB_APP_LOGIN_PATH}?${params.toString()}`
}
export function format(text: string) {
@@ -338,12 +344,14 @@ type UploadResponse = {
export const upload = async (options: UploadOptions, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise<UploadResponse> => {
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
const token = await getAccessToken(isPublicAPI)
const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
const defaultOptions = {
method: 'POST',
url: (url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`) + (searchParams || ''),
headers: {
Authorization: `Bearer ${token}`,
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
[PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
[WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
},
}
const mergedOptions = {
@@ -413,14 +421,17 @@ export const ssePost = async (
} = otherOptions
const abortController = new AbortController()
const token = localStorage.getItem('console_token')
// No need to get token from localStorage, cookies will be sent automatically
const baseOptions = getBaseOptions()
const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
const options = Object.assign({}, baseOptions, {
method: 'POST',
signal: abortController.signal,
headers: new Headers({
Authorization: `Bearer ${token}`,
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
[WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
[PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
}),
} as RequestInit, fetchOptions)
@@ -439,9 +450,6 @@ export const ssePost = async (
if (body)
options.body = JSON.stringify(body)
const accessToken = await getAccessToken(isPublicAPI)
; (options.headers as Headers).set('Authorization', `Bearer ${accessToken}`)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^[23]\d{2}$/.test(String(res.status))) {
@@ -452,15 +460,11 @@ export const ssePost = async (
if (data.code === 'web_app_access_denied')
requiredWebSSOLogin(data.message, 403)
if (data.code === 'web_sso_auth_required') {
removeAccessToken()
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
}
if (data.code === 'unauthorized') {
removeAccessToken()
if (data.code === 'unauthorized')
requiredWebSSOLogin()
}
}
})
}
@@ -551,13 +555,11 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
return Promise.reject(err)
}
if (code === 'web_sso_auth_required') {
removeAccessToken()
requiredWebSSOLogin()
return Promise.reject(err)
}
if (code === 'unauthorized_and_force_logout') {
localStorage.removeItem('console_token')
localStorage.removeItem('refresh_token')
// Cookies will be cleared by the backend
globalThis.location.reload()
return Promise.reject(err)
}
@@ -566,7 +568,6 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
silent,
} = otherOptionsForBaseFetch
if (isPublicAPI && code === 'unauthorized') {
removeAccessToken()
requiredWebSSOLogin()
return Promise.reject(err)
}

View File

@@ -40,7 +40,7 @@ import type { SystemFeatures } from '@/types/feature'
type LoginSuccess = {
result: 'success'
data: { access_token: string; refresh_token: string }
data: { access_token: string }
}
type LoginFail = {
result: 'fail'
@@ -56,10 +56,6 @@ export const webAppLogin: Fetcher<LoginResponse, { url: string; body: Record<str
return post(url, { body }, { isPublicAPI: true }) as Promise<LoginResponse>
}
export const fetchNewToken: Fetcher<CommonResponse & { data: { access_token: string; refresh_token: string } }, { body: Record<string, any> }> = ({ body }) => {
return post('/refresh-token', { body }) as Promise<CommonResponse & { data: { access_token: string; refresh_token: string } }>
}
export const setup: Fetcher<CommonResponse, { body: Record<string, any> }> = ({ body }) => {
return post<CommonResponse>('/setup', { body })
}
@@ -84,10 +80,6 @@ export const updateUserProfile: Fetcher<CommonResponse, { url: string; body: Rec
return post<CommonResponse>(url, { body })
}
export const logout: Fetcher<CommonResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
return get<CommonResponse>(url, params)
}
export const fetchLangGeniusVersion: Fetcher<LangGeniusVersionResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
return get<LangGeniusVersionResponse>(url, { params })
}

View File

@@ -2,9 +2,9 @@ import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } fro
import ky from 'ky'
import type { IOtherOptions } from './base'
import Toast from '@/app/components/base/toast'
import { API_PREFIX, APP_VERSION, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config'
import { getInitialTokenV2, isTokenV1 } from '@/app/components/share/utils'
import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
import Cookies from 'js-cookie'
import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth'
const TIME_OUT = 100000
@@ -69,35 +69,15 @@ const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => {
}
}
export async function getAccessToken(isPublicAPI?: boolean) {
if (isPublicAPI) {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
const accessToken = localStorage.getItem('token') || JSON.stringify({ version: 2 })
let accessTokenJson: Record<string, any> = { version: 2 }
try {
accessTokenJson = JSON.parse(accessToken)
if (isTokenV1(accessTokenJson))
accessTokenJson = getInitialTokenV2()
}
catch {
}
return accessTokenJson[sharedToken]?.[userId || 'DEFAULT']
}
else {
return localStorage.getItem('console_token') || ''
}
}
const beforeRequestPublicAuthorization: BeforeRequestHook = async (request) => {
const token = await getAccessToken(true)
request.headers.set('Authorization', `Bearer ${token}`)
}
const beforeRequestAuthorization: BeforeRequestHook = async (request) => {
const accessToken = await getAccessToken()
request.headers.set('Authorization', `Bearer ${accessToken}`)
const beforeRequestPublicWithCode = (request: Request) => {
request.headers.set('Authorization', `Bearer ${getWebAppAccessToken()}`)
const shareCode = globalThis.location.pathname.split('/').filter(Boolean).pop() || ''
// some pages does not end with share code, so we need to check it
// TODO: maybe find a better way to access app code?
if (shareCode === 'webapp-signin' || shareCode === 'check-code')
return
request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, shareCode)
request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(shareCode))
}
const baseHooks: Hooks = {
@@ -148,6 +128,8 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions:
}
const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`)
if (!isMarketplaceAPI)
(headers as any).set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '')
if (deleteContentType)
(headers as any).delete('Content-Type')
@@ -165,8 +147,7 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions:
],
beforeRequest: [
...baseHooks.beforeRequest || [],
isPublicAPI && beforeRequestPublicAuthorization,
!isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization,
isPublicAPI && beforeRequestPublicWithCode,
].filter((h): h is BeforeRequestHook => Boolean(h)),
afterResponse: [
...baseHooks.afterResponse || [],

View File

@@ -39,7 +39,6 @@ async function getNewAccessToken(timeout: number): Promise<void> {
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
globalThis.addEventListener('beforeunload', releaseRefreshLock)
const refresh_token = globalThis.localStorage.getItem('refresh_token')
// Do not use baseFetch to refresh tokens.
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
@@ -48,10 +47,11 @@ async function getNewAccessToken(timeout: number): Promise<void> {
// that does not call baseFetch and uses a single retry mechanism.
const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST',
credentials: 'include', // Important: include cookies in the request
headers: {
'Content-Type': 'application/json;utf-8',
},
body: JSON.stringify({ refresh_token }),
// No body needed - refresh token is in cookie
}))
if (error) {
return Promise.reject(error)
@@ -59,10 +59,6 @@ async function getNewAccessToken(timeout: number): Promise<void> {
else {
if (ret.status === 401)
return Promise.reject(ret)
const { data } = await ret.json()
globalThis.localStorage.setItem('console_token', data.access_token)
globalThis.localStorage.setItem('refresh_token', data.refresh_token)
}
}
}

View File

@@ -34,6 +34,8 @@ import type {
} from '@/models/share'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import { WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
import { getWebAppAccessToken } from './webapp-auth'
function getAction(action: 'get' | 'post' | 'del' | 'patch', isInstalledApp: boolean) {
switch (action) {
@@ -286,16 +288,14 @@ export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { c
return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true })
}
export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { appCode: string, userId?: string, webAppAccessToken?: string | null }) => {
export const fetchAccessToken = async ({ userId, appCode }: { userId?: string, appCode: string }) => {
const headers = new Headers()
headers.append('X-App-Code', appCode)
headers.append(WEB_APP_SHARE_CODE_HEADER_NAME, appCode)
headers.append('Authorization', `Bearer ${getWebAppAccessToken()}`)
const params = new URLSearchParams()
if (webAppAccessToken)
params.append('web_app_access_token', webAppAccessToken)
if (userId)
params.append('user_id', userId)
userId && params.append('user_id', userId)
const url = `/passport?${params.toString()}`
return get(url, { headers }) as Promise<{ access_token: string }>
return get<{ access_token: string }>(url, { headers }) as Promise<{ access_token: string }>
}
export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => {

View File

@@ -50,7 +50,7 @@ export const useMailValidity = () => {
})
}
export type MailRegisterResponse = { result: string, data: { access_token: string, refresh_token: string } }
export type MailRegisterResponse = { result: string, data: {} }
export const useMailRegister = () => {
return useMutation({
@@ -106,3 +106,23 @@ export const useSchemaTypeDefinitions = () => {
queryFn: () => get<SchemaTypeDefinition[]>('/spec/schema-definitions'),
})
}
type isLogin = {
logged_in: boolean
}
export const useIsLogin = () => {
return useQuery<isLogin>({
queryKey: [NAME_SPACE, 'is-login'],
staleTime: 0,
gcTime: 0,
queryFn: () => get<isLogin>('/login/status'),
})
}
export const useLogout = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'logout'],
mutationFn: () => post('/logout'),
})
}

View File

@@ -8,6 +8,8 @@ export const useGetWebAppAccessModeByCode = (code: string | null) => {
queryKey: [NAME_SPACE, 'appAccessMode', code],
queryFn: () => getAppAccessModeByAppCode(code!),
enabled: !!code,
staleTime: 0, // backend change the access mode may cause the logic error. Because /permission API is no cached.
gcTime: 0,
})
}

View File

@@ -0,0 +1,53 @@
import { ACCESS_TOKEN_LOCAL_STORAGE_NAME, PASSPORT_LOCAL_STORAGE_NAME } from '@/config'
import { getPublic, postPublic } from './base'
export function setWebAppAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME, token)
}
export function setWebAppPassport(shareCode: string, token: string) {
localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token)
}
export function getWebAppAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) || ''
}
export function getWebAppPassport(shareCode: string) {
return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || ''
}
export function clearWebAppAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME)
}
export function clearWebAppPassport(shareCode: string) {
localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode))
}
type isWebAppLogin = {
logged_in: boolean
app_logged_in: boolean
}
export async function webAppLoginStatus(enabled: boolean, shareCode: string) {
if (!enabled) {
return {
userLoggedIn: true,
appLoggedIn: true,
}
}
// check remotely, the access token could be in cookie (enterprise SSO redirected with https)
const { logged_in, app_logged_in } = await getPublic<isWebAppLogin>(`/login/status?app_code=${shareCode}`)
return {
userLoggedIn: logged_in,
appLoggedIn: app_logged_in,
}
}
export async function webAppLogout(shareCode: string) {
clearWebAppAccessToken()
clearWebAppPassport(shareCode)
await postPublic('/logout')
}