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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
53
web/service/webapp-auth.ts
Normal file
53
web/service/webapp-auth.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user