Files
bedolaga-cabinet/src/utils/token.ts
2026-01-18 05:34:15 +03:00

291 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Утилиты для безопасной работы с JWT токенами
*/
import axios from 'axios'
const TOKEN_KEYS = {
ACCESS: 'access_token',
REFRESH: 'refresh_token',
USER: 'user',
TELEGRAM_INIT: 'telegram_init_data',
} as const
interface JWTPayload {
exp?: number
iat?: number
sub?: string
[key: string]: unknown
}
/**
* Декодирует JWT токен без верификации подписи
* Используется только для чтения payload на клиенте
*/
export function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = parts[1]
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(decoded)
} catch {
return null
}
}
/**
* Проверяет, истёк ли срок действия токена
* @param token JWT токен
* @param bufferSeconds Буфер в секундах до истечения (по умолчанию 30 сек)
*/
export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean {
if (!token) return true
const payload = decodeJWT(token)
if (!payload?.exp) return true
const now = Math.floor(Date.now() / 1000)
return payload.exp <= now + bufferSeconds
}
/**
* Проверяет, валиден ли токен (не истёк и корректный формат)
*/
export function isTokenValid(token: string | null): boolean {
if (!token) return false
return !isTokenExpired(token)
}
/**
* Безопасное хранилище токенов
* Использует sessionStorage вместо localStorage для защиты от XSS
* Токены не сохраняются между сессиями браузера
*/
export const tokenStorage = {
getAccessToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEYS.ACCESS)
} catch {
return null
}
},
getRefreshToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEYS.REFRESH)
} catch {
return null
}
},
setTokens(accessToken: string, refreshToken: string): void {
try {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken)
} catch {
console.error('Failed to save tokens to sessionStorage')
}
},
setAccessToken(accessToken: string): void {
try {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
} catch {
console.error('Failed to save access token to sessionStorage')
}
},
clearTokens(): void {
try {
sessionStorage.removeItem(TOKEN_KEYS.ACCESS)
sessionStorage.removeItem(TOKEN_KEYS.REFRESH)
sessionStorage.removeItem(TOKEN_KEYS.USER)
// Также очищаем localStorage для миграции со старой версии
localStorage.removeItem(TOKEN_KEYS.ACCESS)
localStorage.removeItem(TOKEN_KEYS.REFRESH)
localStorage.removeItem(TOKEN_KEYS.USER)
} catch {
// ignore
}
},
/**
* Миграция токенов из localStorage в sessionStorage
* Вызывается при инициализации для обратной совместимости
*/
migrateFromLocalStorage(): void {
try {
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS)
const refreshToken = localStorage.getItem(TOKEN_KEYS.REFRESH)
if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
}
if (refreshToken && !sessionStorage.getItem(TOKEN_KEYS.REFRESH)) {
sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken)
}
// Удаляем из localStorage после миграции
localStorage.removeItem(TOKEN_KEYS.ACCESS)
localStorage.removeItem(TOKEN_KEYS.REFRESH)
} catch {
// ignore
}
},
getTelegramInitData(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT)
} catch {
return null
}
},
setTelegramInitData(data: string): void {
try {
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data)
} catch {
// ignore
}
},
}
/**
* Централизованный менеджер обновления токенов
* Предотвращает множественные параллельные refresh запросы
*/
class TokenRefreshManager {
private isRefreshing = false
private refreshPromise: Promise<string | null> | null = null
private subscribers: ((token: string | null) => void)[] = []
private refreshEndpoint = '/api/cabinet/auth/refresh'
setRefreshEndpoint(endpoint: string): void {
this.refreshEndpoint = endpoint
}
/**
* Обновляет access token используя refresh token
* При множественных вызовах возвращает один и тот же Promise
*/
async refreshAccessToken(): Promise<string | null> {
// Если уже идёт refresh - возвращаем существующий Promise
if (this.isRefreshing && this.refreshPromise) {
return this.refreshPromise
}
const refreshToken = tokenStorage.getRefreshToken()
if (!refreshToken) {
return null
}
this.isRefreshing = true
this.refreshPromise = this.doRefresh(refreshToken)
try {
const result = await this.refreshPromise
this.notifySubscribers(result)
return result
} finally {
this.isRefreshing = false
this.refreshPromise = null
}
}
private async doRefresh(refreshToken: string): Promise<string | null> {
try {
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
const response = await axios.post<{ access_token?: string }>(
this.refreshEndpoint,
{ refresh_token: refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
)
const newAccessToken = response.data.access_token
if (newAccessToken) {
tokenStorage.setAccessToken(newAccessToken)
return newAccessToken
}
return null
} catch (error) {
console.error('[TokenRefreshManager] Refresh failed:', error)
return null
}
}
/**
* Подписка на результат refresh (для ожидающих запросов)
*/
subscribe(callback: (token: string | null) => void): () => void {
this.subscribers.push(callback)
return () => {
this.subscribers = this.subscribers.filter((cb) => cb !== callback)
}
}
private notifySubscribers(token: string | null): void {
this.subscribers.forEach((cb) => cb(token))
this.subscribers = []
}
/**
* Проверяет, идёт ли сейчас refresh
*/
get isRefreshInProgress(): boolean {
return this.isRefreshing
}
/**
* Ожидает завершения текущего refresh (если есть)
*/
async waitForRefresh(): Promise<string | null> {
if (this.refreshPromise) {
return this.refreshPromise
}
return tokenStorage.getAccessToken()
}
}
export const tokenRefreshManager = new TokenRefreshManager()
/**
* Безопасный редирект на страницу логина
* Валидирует URL чтобы предотвратить open redirect уязвимость
*/
export function safeRedirectToLogin(): void {
// Разрешённые пути для редиректа (относительные пути нашего приложения)
const loginPath = '/login'
// Проверяем, что мы на том же origin
if (typeof window !== 'undefined') {
// Используем только относительный путь для безопасности
window.location.href = loginPath
}
}
/**
* Валидирует URL для редиректа
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
*/
export function isValidRedirectUrl(url: string): boolean {
// Пустой URL - небезопасен
if (!url) return false
// Относительные пути всегда безопасны
if (url.startsWith('/') && !url.startsWith('//')) {
return true
}
try {
const parsed = new URL(url, window.location.origin)
// Разрешаем только тот же origin
return parsed.origin === window.location.origin
} catch {
return false
}
}