diff --git a/apps/daas/src/main.ts b/apps/daas/src/main.ts index 66f17344f5..2479080596 100644 --- a/apps/daas/src/main.ts +++ b/apps/daas/src/main.ts @@ -18,7 +18,12 @@ import App from '@/App.vue' import { installOEM } from '@/oem' import { installAllPlugins } from '@/plugins' import { initRequestClient } from '@/plugins/axios' -import { configUser, getUrlSearch, signOut } from '@/utils/util' +import { + configUser, + getUrlSearch, + removeUrlParams, + signOut, +} from '@/utils/util' import store from '@/vuex' // 引入全局数据控制 import { installDirectives } from './directives' import i18n from './i18n' @@ -71,6 +76,7 @@ const URL_LANG = getUrlSearch('lang') if (TOKEN) { Cookie.set('access_token', TOKEN) + removeUrlParams('token') } const token = Cookie.get('access_token') @@ -100,9 +106,8 @@ const init = () => { app.config.globalProperties.$ws = new WSClient(wsUrl, undefined, { getQuery: () => { - return { - access_token: Cookie.get('access_token'), - } + // Same-origin WS sends the access_token cookie; do not put the token in the URL (TAP-11883). + return {} }, }) diff --git a/apps/daas/src/plugins/axios.ts b/apps/daas/src/plugins/axios.ts index 3dc5484346..3afb7a320a 100644 --- a/apps/daas/src/plugins/axios.ts +++ b/apps/daas/src/plugins/axios.ts @@ -19,6 +19,7 @@ import { signOut } from '../utils/util' type AxiosRequestConfigPro = AxiosRequestConfig & { silenceMessage?: boolean + skipAuthExpire?: boolean } // Pending requests map for cancellation @@ -60,6 +61,9 @@ const errorCallback = (error: any): Promise => { switch (rsp.status) { // 用户无权限访问接口 case 401: { + if ((error.config as AxiosRequestConfigPro)?.skipAuthExpire) { + break + } const isSingleSession = getSettingByKey('login.single.session', 'open') signOut() @@ -114,6 +118,35 @@ const errorCallback = (error: any): Promise => { return Promise.reject(error) } +function stripUrlAccessToken(url?: string) { + if (!url) { + return url + } + let next = url.replaceAll(/([?&])access_token=[^&]*/gi, '$1') + next = next.replaceAll('?&', '?').replaceAll('&&', '&') + if (next.endsWith('?') || next.endsWith('&')) { + next = next.slice(0, -1) + } + return next +} + +function applyBearerAuth(config: InternalAxiosRequestConfig) { + const accessToken = Cookie.get('access_token') + if (accessToken) { + config.headers = config.headers || {} + if (!config.headers.Authorization) { + config.headers.Authorization = `Bearer ${accessToken}` + } + } + if (config.url) { + config.url = stripUrlAccessToken(config.url) + } + if (config.params && typeof config.params === 'object') { + delete (config.params as Record).access_token + } + return config +} + axios.interceptors.request.use(function ( config: InternalAxiosRequestConfig, ): InternalAxiosRequestConfig { @@ -123,16 +156,7 @@ axios.interceptors.request.use(function ( encoder: (str) => window.encodeURIComponent(str), }) } - const accessToken = Cookie.get('access_token') - if (accessToken && config.url) { - if (~config.url.indexOf('?')) { - if (!~config.url.indexOf('access_token')) { - config.url = `${config.url}&access_token=${accessToken}` - } - } else { - config.url = `${config.url}?access_token=${accessToken}` - } - } + applyBearerAuth(config) if (config.headers) { config.headers['x-requested-with'] = 'XMLHttpRequest' } @@ -170,16 +194,7 @@ export function initRequestClient() { requestClient.addRequestInterceptor({ synchronous: true, fulfilled: (config) => { - const accessToken = Cookie.get('access_token') - if (accessToken) { - if (config.url?.indexOf?.('?') !== -1) { - if (config.url?.indexOf?.('access_token') === -1) { - config.url = `${config.url}&access_token=${accessToken}` - } - } else { - config.url = `${config.url}?access_token=${accessToken}` - } - } + applyBearerAuth(config) if (isPassiveScope()) { config.headers = config.headers || {} config.headers['X-User-Activity'] = '0' diff --git a/apps/daas/src/utils/util.ts b/apps/daas/src/utils/util.ts index 2c9ba536a9..9d54b15ffd 100644 --- a/apps/daas/src/utils/util.ts +++ b/apps/daas/src/utils/util.ts @@ -1,4 +1,4 @@ -import { getSamlLogoutUrl } from '@tap/api/src/core/sso' +import { startSamlLogout } from '@tap/api/src/core/sso' import { getUserInfoByToken } from '@tap/api/src/core/users' import Cookie from '@tap/shared/src/cookie' import dayjs from 'dayjs' @@ -160,17 +160,55 @@ export function signOut() { Cookie.remove('user_id') Cookie.remove('auth_method') clearPermissions() + const loginHref = `${location.href.split('#')[0]}#/login` if (isSamlSession) { - // The backend terminates the local session, then redirects to the IdP SLO - // endpoint (or straight back to the login page when SLO is not configured). - window.location.href = getSamlLogoutUrl(accessToken) + // The backend terminates the local session, then returns the IdP SLO + // endpoint (or the login page when SLO is not configured) to navigate to. + startSamlLogout(accessToken) + .then(({ redirectUrl }) => { + location.href = redirectUrl || loginHref + }) + .catch(() => { + location.href = loginHref + }) return null } sessionStorage.setItem('lastLocationHref', location.href) - location.href = `${location.href.split('#')[0]}#/login` + location.href = loginHref return null } +/** + * Drop credential params from the address bar without adding a history entry, + * so the token is not kept in history or later copied into lastLocationHref + * (TAP-11883). + */ +export function removeUrlParams(...names: string[]) { + const search = new URLSearchParams(location.search) + const [hashPath, hashQuery = ''] = location.hash.split('?') + const hashParams = new URLSearchParams(hashQuery) + if (!names.some((name) => search.has(name) || hashParams.has(name))) { + return + } + for (const name of names) { + search.delete(name) + hashParams.delete(name) + } + const nextSearch = search.toString() ? `?${search}` : '' + const nextHash = hashParams.toString() + ? `${hashPath}?${hashParams}` + : hashPath + // vue-router keeps the full route in history.state.current; drop it there too. + const state = history.state?.current + ? { ...history.state, current: nextHash.slice(1) } + : history.state + history.replaceState( + state, + '', + `${location.pathname}${nextSearch}${nextHash}`, + ) +} + export function getUrlSearch(name: string) { // 未传参,返回空 if (!name) return null diff --git a/apps/daas/src/views/api-page/ApiServer.vue b/apps/daas/src/views/api-page/ApiServer.vue index 6767c24936..507018808d 100644 --- a/apps/daas/src/views/api-page/ApiServer.vue +++ b/apps/daas/src/views/api-page/ApiServer.vue @@ -9,8 +9,8 @@ import { import PageContainer from '@tap/business/src/components/PageContainer.vue' import TablePage from '@tap/business/src/components/TablePage.vue' import { useHas } from '@tap/business/src/composables' +import { downloadAuthenticated } from '@tap/api/src/download' import i18n from '@tap/i18n' -import Cookie from '@tap/shared/src/cookie' import { ElMessage, ElMessageBox } from 'element-plus' import { escapeRegExp } from 'lodash-es' import { h, nextTick, reactive, ref } from 'vue' @@ -113,11 +113,7 @@ const remove = (item: Record) => { // 下载api配置文件 const downloadConfig = (item: Record) => { - const token = Cookie.get('access_token') - window.open( - `${API_SERVER_BASE_URL}/download/${item.id}?access_token=${token}`, - '_blank', - ) + downloadAuthenticated(`${API_SERVER_BASE_URL}/download/${item.id}`) } // 保存 diff --git a/apps/daas/src/views/login/SsoCallback.vue b/apps/daas/src/views/login/SsoCallback.vue index 84bdd82d6f..d91ef10acd 100644 --- a/apps/daas/src/views/login/SsoCallback.vue +++ b/apps/daas/src/views/login/SsoCallback.vue @@ -3,7 +3,7 @@ import { fetchSettings } from '@tap/api/core/settings' import { getUserInfoByToken } from '@tap/api/src/core/users' import Cookie from '@tap/shared/src/cookie' import { setSettings } from '@tap/shared/src/settings' -import { configUser, getUrlSearch } from '@/utils/util' +import { configUser, getUrlSearch, removeUrlParams } from '@/utils/util' import LoginPage from './LoginPage.vue' export default { @@ -25,6 +25,9 @@ export default { // to the hash route. Convert that URL token into a cookie and run the same // post-login bootstrap as the password path before the router guard runs. const accessToken = getUrlSearch('access_token') + // Strip the token before any await so it never stays in the address bar + // or history, nor ends up in a later location.href (TAP-11883). + removeUrlParams('access_token') if (!accessToken) { this.redirectToLogin() return diff --git a/packages/api/src/JavascriptFunctions.ts b/packages/api/src/JavascriptFunctions.ts index 8c74034fb9..a2e9d70008 100644 --- a/packages/api/src/JavascriptFunctions.ts +++ b/packages/api/src/JavascriptFunctions.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from './download' import Http from './Http' export default class JavascriptFunctions extends Http { @@ -6,10 +6,9 @@ export default class JavascriptFunctions extends Http { super('/api/Javascript_functions') } export(ids) { - const href = `${ - this.url - }/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${this.url}/batch/load?id=${ids.join('&id=')}`, + ) } } export { JavascriptFunctions } diff --git a/packages/api/src/Modules.js b/packages/api/src/Modules.js index 62fffcc9e0..77a640b0fb 100644 --- a/packages/api/src/Modules.js +++ b/packages/api/src/Modules.js @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from './download' import Http from './Http' export default class Modules extends Http { @@ -50,17 +50,15 @@ export default class Modules extends Http { } export(ids) { - const href = `${ - this.url - }/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${this.url}/batch/load?id=${ids.join('&id=')}`, + ) } apiExport(ids, ip) { - const href = `${ - this.url - }/api/export?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}&ip=${ip}` - window.open(href) + return downloadAuthenticated( + `${this.url}/api/export?id=${ids.join('&id=')}&ip=${ip}`, + ) } updatePermissions(data) { diff --git a/packages/api/src/PythonFunctions.ts b/packages/api/src/PythonFunctions.ts index ef25c4e021..2f33b5bc9f 100644 --- a/packages/api/src/PythonFunctions.ts +++ b/packages/api/src/PythonFunctions.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from './download' import Http from './Http' export default class PythonFunctions extends Http { @@ -6,10 +6,9 @@ export default class PythonFunctions extends Http { super('/api/python-functions') } export(ids) { - const href = `${ - this.url - }/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${this.url}/batch/load?id=${ids.join('&id=')}`, + ) } } export { PythonFunctions } diff --git a/packages/api/src/Task.ts b/packages/api/src/Task.ts index f1cfbc8d82..bc2bb01d22 100644 --- a/packages/api/src/Task.ts +++ b/packages/api/src/Task.ts @@ -1,6 +1,6 @@ import { isPlainObj } from '@tap/shared' -import Cookie from '@tap/shared/src/cookie' import qs from 'qs' +import { downloadAuthenticated } from './download' import Http from './Http' export default class Task extends Http { @@ -81,10 +81,9 @@ export default class Task extends Http { } export(ids) { - const href = `${ - this.url - }/batch/load?taskId=${ids.join('&taskId=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${this.url}/batch/load?taskId=${ids.join('&taskId=')}`, + ) } checkRun(id) { return this.axios.get(`${this.url}/checkRun/${id}`) diff --git a/packages/api/src/Users.ts b/packages/api/src/Users.ts index d1893fda25..fbb42a19dc 100644 --- a/packages/api/src/Users.ts +++ b/packages/api/src/Users.ts @@ -44,7 +44,9 @@ export default class Users extends Http { } newResetPassword(token) { - return this.axios.post(`${this.url}/newResetPassword?access_token=${token}`) + return this.axios.post(`${this.url}/newResetPassword`, undefined, { + headers: { Authorization: `Bearer ${token}` }, + }) } confirm(id, token) { diff --git a/packages/api/src/core/connections.ts b/packages/api/src/core/connections.ts index 0a58ef3c6c..1abef289b5 100644 --- a/packages/api/src/core/connections.ts +++ b/packages/api/src/core/connections.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient, type Filter } from '../request' const BASE_URL = '/api/Connections' @@ -115,8 +115,9 @@ export function findOneConnection(params: any) { } export function exportConnections(ids: string[]) { - const href = `${BASE_URL}/batch/load?connectionId=${ids.join('&connectionId=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${BASE_URL}/batch/load?connectionId=${ids.join('&connectionId=')}`, + ) } export function uploadConnections(data: any) { diff --git a/packages/api/src/core/function.ts b/packages/api/src/core/function.ts index 93452cc153..1fcaec0133 100644 --- a/packages/api/src/core/function.ts +++ b/packages/api/src/core/function.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient } from '../request' const BASE_URL = '/api/Javascript_functions' @@ -53,8 +53,7 @@ export function updateFunction(where: any, attributes: any) { } export function exportFunctions(ids: string[]) { - const href = `${BASE_URL}/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated(`${BASE_URL}/batch/load?id=${ids.join('&id=')}`) } export function uploadFunctions(data: any) { diff --git a/packages/api/src/core/javascript-functions.ts b/packages/api/src/core/javascript-functions.ts index 8a1cb846b5..ec37410859 100644 --- a/packages/api/src/core/javascript-functions.ts +++ b/packages/api/src/core/javascript-functions.ts @@ -1,8 +1,7 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' const BASE_URL = '/api/Javascript_functions' export function exportJavascriptFunctions(ids: string[]) { - const href = `${BASE_URL}/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated(`${BASE_URL}/batch/load?id=${ids.join('&id=')}`) } diff --git a/packages/api/src/core/metadata-instances.ts b/packages/api/src/core/metadata-instances.ts index 7b7719c0a5..e81319a0bd 100644 --- a/packages/api/src/core/metadata-instances.ts +++ b/packages/api/src/core/metadata-instances.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient, type PageFetchResult } from '../request' const BASE_URL = '/api/MetadataInstances' @@ -17,9 +17,8 @@ export function updateMetadataInstanceClassification(params: any) { export function downloadMetadataInstance(where: any, type: string) { if (typeof where === 'object') where = JSON.stringify(where) - const accessToken = Cookie.get('access_token') as string | undefined - window.open( - `${BASE_URL}/download?where=${encodeURIComponent(where)}&type=${type}&access_token=${encodeURIComponent(accessToken || '')}`, + return downloadAuthenticated( + `${BASE_URL}/download?where=${encodeURIComponent(where)}&type=${type}`, ) } diff --git a/packages/api/src/core/modules.ts b/packages/api/src/core/modules.ts index 22f0311343..edba9e19c6 100644 --- a/packages/api/src/core/modules.ts +++ b/packages/api/src/core/modules.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient } from '../request' const BASE_URL = '/api/Modules' @@ -148,16 +148,16 @@ export function getApiModuleList(params: any) { * 导出 API 模块 - 批量导出模块配置 */ export function exportApiModules(ids: string[]) { - const href = `${BASE_URL}/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated(`${BASE_URL}/batch/load?id=${ids.join('&id=')}`) } /** * 导出 API 文档 - 导出 API 接口文档 */ export function exportApiDocumentation(ids: string[], ip: string) { - const href = `${BASE_URL}/api/export?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}&ip=${ip}` - window.open(href) + return downloadAuthenticated( + `${BASE_URL}/api/export?id=${ids.join('&id=')}&ip=${ip}`, + ) } /** diff --git a/packages/api/src/core/python-functions.ts b/packages/api/src/core/python-functions.ts index 23e3de99fa..cb81bf60ac 100644 --- a/packages/api/src/core/python-functions.ts +++ b/packages/api/src/core/python-functions.ts @@ -1,4 +1,4 @@ -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient } from '../request' const BASE_URL = '/api/python-functions' @@ -10,6 +10,5 @@ export function fetchPythonFunctions(filter?: any) { } export function exportPythonFunctions(ids: string[]) { - const href = `${BASE_URL}/batch/load?id=${ids.join('&id=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated(`${BASE_URL}/batch/load?id=${ids.join('&id=')}`) } diff --git a/packages/api/src/core/sso.ts b/packages/api/src/core/sso.ts index 933d11caed..8db7780d69 100644 --- a/packages/api/src/core/sso.ts +++ b/packages/api/src/core/sso.ts @@ -28,15 +28,22 @@ export function getSamlLoginUrl() { } /** - * Absolute URL for SP-initiated Single Logout. The browser must navigate to it - * directly; the backend terminates the local session, then redirects to the IdP - * SLO endpoint (or straight to the login page when SLO is not configured). + * Start SP-initiated Single Logout. The token goes in the Authorization header, + * never the URL (TAP-11883); the backend terminates the local session and returns + * where the browser should navigate next: the IdP SLO endpoint, or the login page + * when SLO is not configured. */ -export function getSamlLogoutUrl(accessToken?: string) { - const relay = `relayState=${encodeURIComponent(SSO_LOGOUT_RELAY_STATE)}` - return accessToken - ? `${SSO_BASE_URL}/logout?access_token=${encodeURIComponent(accessToken)}&${relay}` - : `${SSO_BASE_URL}/logout?${relay}` +export function startSamlLogout(accessToken?: string) { + return requestClient.post<{ redirectUrl: string }>( + `${SSO_BASE_URL}/logout`, + { relayState: SSO_LOGOUT_RELAY_STATE }, + { + headers: accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined, + skipErrorHandler: true, + }, + ) } /** IdP fields extracted from an imported IdP SAML metadata document. */ diff --git a/packages/api/src/core/task.ts b/packages/api/src/core/task.ts index 97888b3df1..ec1706f618 100644 --- a/packages/api/src/core/task.ts +++ b/packages/api/src/core/task.ts @@ -1,5 +1,5 @@ import { isPlainObj } from '@tap/shared' -import Cookie from '@tap/shared/src/cookie' +import { downloadAuthenticated } from '../download' import { requestClient, type Page } from '../request' const BASE_URL = '/api/Task' @@ -131,8 +131,9 @@ export function editTask(params: any) { } export function exportTasks(ids: string[]) { - const href = `${BASE_URL}/batch/load?taskId=${ids.join('&taskId=')}&access_token=${Cookie.get('access_token')}` - window.open(href) + return downloadAuthenticated( + `${BASE_URL}/batch/load?taskId=${ids.join('&taskId=')}`, + ) } export function checkTaskRun(id: string) { diff --git a/packages/api/src/core/users.ts b/packages/api/src/core/users.ts index d1dd80f2c5..a9364f9ad8 100644 --- a/packages/api/src/core/users.ts +++ b/packages/api/src/core/users.ts @@ -45,9 +45,9 @@ export function checkToken() { } export function newResetPassword(token: string) { - return requestClient.post( - `${BASE_URL}/newResetPassword?access_token=${token}`, - ) + return requestClient.post(`${BASE_URL}/newResetPassword`, undefined, { + headers: { Authorization: `Bearer ${token}` }, + }) } export function confirmUser(id: string, token: string) { diff --git a/packages/api/src/download.ts b/packages/api/src/download.ts new file mode 100644 index 0000000000..cca7e312b4 --- /dev/null +++ b/packages/api/src/download.ts @@ -0,0 +1,12 @@ +import { downloadBlob } from '@tap/shared' +import axios from 'axios' + +/** + * Download a same-origin TM/API file with Authorization: Bearer. + * Do not put the session token in the URL (TAP-11883). + */ +export function downloadAuthenticated(url: string, filename?: string) { + return axios.get(url, { responseType: 'blob' }).then((res) => { + downloadBlob(res, filename) + }) +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0548d2f2d5..101ad1e231 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -5,6 +5,7 @@ export { CancelToken, isCancel } export default Http export * from './request' +export { downloadAuthenticated } from './download' // export * from './core' export { useRequest } from 'vue-request' diff --git a/packages/business/src/components/logs/Download.vue b/packages/business/src/components/logs/Download.vue index e4e7d0acfc..cf3bc210cb 100644 --- a/packages/business/src/components/logs/Download.vue +++ b/packages/business/src/components/logs/Download.vue @@ -1,8 +1,8 @@