-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement CSRF support in the UI (#12354)
Co-authored-by: Nicholas Brown <znicholasbrown@gmail.com> Co-authored-by: Craig Harshbarger <pleek91@gmail.com>
- Loading branch information
1 parent
2d15b0a
commit 42839e9
Showing
7 changed files
with
172 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { CsrfToken } from "@/models/CsrfToken" | ||
import { CsrfTokenResponse } from "@/types/csrfTokenResponse" | ||
import { MapFunction } from '@/services/mapper' | ||
|
||
export const mapCsrfTokenResponseToCsrfToken: MapFunction<CsrfTokenResponse, CsrfToken> = function(source) { | ||
return { | ||
token: source.token, | ||
expiration: this.map('string', source.expiration, 'Date'), | ||
issued: new Date(), | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,11 @@ | ||
import { maps as designMaps } from '@prefecthq/prefect-ui-library' | ||
import { mapFlagResponseToFeatureFlag } from '@/maps/featureFlag' | ||
import { mapSettingsResponseToSettings } from '@/maps/uiSettings' | ||
import { mapCsrfTokenResponseToCsrfToken } from '@/maps/csrfToken' | ||
|
||
export const maps = { | ||
...designMaps, | ||
FlagResponse: { FeatureFlag: mapFlagResponseToFeatureFlag }, | ||
SettingsResponse: { Settings: mapSettingsResponseToSettings }, | ||
} | ||
CsrfTokenResponse: { CsrfToken: mapCsrfTokenResponseToCsrfToken }, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export type CsrfToken = { | ||
token: string | ||
expiration: Date | ||
issued: Date | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
import { AxiosError, AxiosInstance, InternalAxiosRequestConfig, isAxiosError } from 'axios' | ||
import { randomId } from '@prefecthq/prefect-design' | ||
import { Api, AxiosInstanceSetupHook, PrefectConfig } from '@prefecthq/prefect-ui-library' | ||
import { CreateActions } from '@prefecthq/vue-compositions' | ||
import { CsrfToken } from '@/models/CsrfToken' | ||
import { CsrfTokenResponse } from '@/types/csrfTokenResponse' | ||
import { mapper } from '@/services/mapper' | ||
|
||
export class CsrfTokenApi extends Api { | ||
public csrfToken?: CsrfToken | ||
public clientId: string = randomId() | ||
public csrfSupportEnabled = true | ||
private refreshTimeout: ReturnType<typeof setTimeout> | null = null | ||
private ongoingRefresh: Promise<void> | null = null | ||
|
||
public constructor(apiConfig: PrefectConfig, instanceSetupHook: AxiosInstanceSetupHook | null = null) { | ||
super(apiConfig, instanceSetupHook) | ||
this.startBackgroundTokenRefresh() | ||
} | ||
|
||
public async addCsrfHeaders(config: InternalAxiosRequestConfig) { | ||
if (this.csrfSupportEnabled) { | ||
const csrfToken = await this.getCsrfToken() | ||
config.headers = config.headers || {} | ||
config.headers['Prefect-Csrf-Token'] = csrfToken.token | ||
config.headers['Prefect-Csrf-Client'] = this.clientId | ||
} | ||
} | ||
|
||
private async getCsrfToken(): Promise<CsrfToken> { | ||
if (this.shouldRefreshToken()) { | ||
await this.refreshCsrfToken() | ||
} | ||
|
||
if (!this.csrfToken) { | ||
throw new Error('CSRF token not available') | ||
} | ||
|
||
return this.csrfToken | ||
} | ||
|
||
private async refreshCsrfToken(force: boolean = false) { | ||
if (!force && !this.shouldRefreshToken()) { | ||
return this.ongoingRefresh ?? Promise.resolve() | ||
} | ||
|
||
if (this.ongoingRefresh) { | ||
return this.ongoingRefresh | ||
} | ||
|
||
const refresh = async () => { | ||
try { | ||
|
||
const response = await this.get<CsrfTokenResponse>(`/csrf-token?client=${this.clientId}`) | ||
this.csrfToken = mapper.map('CsrfTokenResponse', response.data, 'CsrfToken') | ||
this.ongoingRefresh = null | ||
} catch (error) { | ||
this.ongoingRefresh = null | ||
if (isAxiosError(error)) { | ||
if (this.isUnconfiguredServer(error)) { | ||
this.disableCsrfSupport() | ||
} else { | ||
console.error('Failed to refresh CSRF token:', error) | ||
throw new Error('Failed to refresh CSRF token') | ||
} | ||
} | ||
} | ||
} | ||
|
||
this.ongoingRefresh = refresh() | ||
return this.ongoingRefresh | ||
} | ||
|
||
private shouldRefreshToken(): boolean { | ||
if (!this.csrfSupportEnabled) { | ||
return false | ||
} | ||
|
||
if (!this.csrfToken) { | ||
return true; | ||
} | ||
|
||
if (!this.csrfToken.token || !this.csrfToken.expiration) { | ||
return true | ||
} | ||
|
||
return new Date() > this.csrfToken.expiration | ||
} | ||
|
||
private isUnconfiguredServer(error: AxiosError<any, any>): boolean { | ||
return error.response?.status === 422 && error.response?.data.detail.includes('CSRF protection is disabled.') | ||
} | ||
|
||
private disableCsrfSupport() { | ||
this.csrfSupportEnabled = false | ||
} | ||
|
||
private startBackgroundTokenRefresh() { | ||
const calculateTimeoutDuration = () => { | ||
if (this.csrfToken) { | ||
const now = new Date() | ||
const expiration = new Date(this.csrfToken.expiration) | ||
const issuedAt = this.csrfToken.issued | ||
const lifetime = expiration.getTime() - issuedAt.getTime() | ||
const refreshThreshold = issuedAt.getTime() + lifetime * 0.75 | ||
const durationUntilRefresh = refreshThreshold - now.getTime() | ||
|
||
return durationUntilRefresh | ||
} | ||
return 0 // If we don't have token data cause an immediate refresh | ||
} | ||
|
||
const refreshTask = async () => { | ||
if (this.csrfSupportEnabled) { | ||
await this.refreshCsrfToken(true) | ||
this.refreshTimeout = setTimeout(refreshTask, calculateTimeoutDuration()) | ||
} | ||
} | ||
|
||
this.refreshTimeout = setTimeout(refreshTask, calculateTimeoutDuration()) | ||
} | ||
} | ||
|
||
export function setupCsrfInterceptor(csrfTokenApi: CreateActions<CsrfTokenApi>, axiosInstance: AxiosInstance) { | ||
axiosInstance.interceptors.request.use(async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => { | ||
const method = config.method?.toLowerCase() | ||
|
||
if (method && ['post', 'patch', 'put', 'delete'].includes(method)) { | ||
await csrfTokenApi.addCsrfHeaders(config) | ||
} | ||
|
||
return config | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export type CsrfTokenResponse = { | ||
token: string | ||
expiration: Date | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters