Implement rate limiting and enhance authentication flow
- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables. - Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens. - Introduced a new endpoint to retrieve user subscription details. - Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens. - Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript. - Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { ApiError } from './types'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
if (!response.ok) return false
|
||||
const data = (await response.json()) as { accessToken: string }
|
||||
setAccessToken(data.accessToken)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
export class HttpError extends Error implements ApiError {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
|
||||
constructor(problem: Partial<ApiError>, status: number) {
|
||||
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
|
||||
this.title = problem.title ?? 'Error'
|
||||
this.detail = problem.detail ?? this.message
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<HttpError> {
|
||||
try {
|
||||
const problem = (await response.json()) as Partial<ApiError>
|
||||
return new HttpError(problem, response.status)
|
||||
} catch {
|
||||
return new HttpError({ title: response.statusText }, response.status)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
|
||||
if (response.status === 401 && !options.skipRefresh) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
|
||||
onUnauthorized?.()
|
||||
throw await parseError(response)
|
||||
}
|
||||
|
||||
if (!response.ok) throw await parseError(response)
|
||||
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
const text = await response.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
// Типы вручную синхронизированы с DTO бэкенда (см. backend/src/PnvPanel.Application/**).
|
||||
// TODO: заменить на `pnpm gen:api` (openapi-typescript), когда бэкенд доступен по сети
|
||||
// (сейчас недоступен локально — Postgres/Docker не подняты, схему /openapi/v1.json взять негде).
|
||||
|
||||
export type ApiError = {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type VpnProtocol = 'Vless' | 'Vmess' | 'Trojan' | 'Shadowsocks'
|
||||
export type ConfigStatus = 'Active' | 'Disabled' | 'Expired' | 'LimitReached' | 'Revoked'
|
||||
export type NodeStatus = 'Unknown' | 'Online' | 'Offline'
|
||||
export type ActivationStatus = 'Pending' | 'Approved' | 'Rejected'
|
||||
export type OsPlatform = 'IOS' | 'Android' | 'Windows' | 'MacOS' | 'Linux'
|
||||
export type TelegramLoginStatus = 'Pending' | 'Approved' | 'Rejected' | 'Expired' | 'Consumed'
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
isActivated: boolean
|
||||
telegramLinked: boolean
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: CurrentUser
|
||||
}
|
||||
|
||||
export type RegisterResponse = {
|
||||
id: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
export type ActivationRequestDto = {
|
||||
id: string
|
||||
comment: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ActivationStatusDto = {
|
||||
isActivated: boolean
|
||||
pendingRequest: ActivationRequestDto | null
|
||||
}
|
||||
|
||||
export type VpnConfigDto = {
|
||||
id: string
|
||||
label: string | null
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
deviceLimit: number
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
expiresAt: string | null
|
||||
status: ConfigStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AvailableInboundDto = {
|
||||
inboundId: string
|
||||
displayName: string
|
||||
protocol: VpnProtocol
|
||||
}
|
||||
|
||||
export type ConfigLinkDto = {
|
||||
connectionString: string
|
||||
subscriptionUrl: string
|
||||
}
|
||||
|
||||
export type MySubscriptionDto = {
|
||||
subscriptionUrl: string
|
||||
}
|
||||
|
||||
export type GetMyConfigsResult = {
|
||||
configs: VpnConfigDto[]
|
||||
maxConfigs: number
|
||||
}
|
||||
|
||||
export type ClientAppDto = {
|
||||
id: string
|
||||
name: string
|
||||
downloadUrl: string
|
||||
description: string | null
|
||||
iconUrl: string | null
|
||||
}
|
||||
|
||||
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
|
||||
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
|
||||
|
||||
export type LinkTokenResponse = {
|
||||
deepLink: string | null
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type TelegramLoginRequestResponse = {
|
||||
requestId: string
|
||||
deepLink: string | null
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type TelegramLoginStatusResponse = {
|
||||
status: TelegramLoginStatus
|
||||
accessToken?: string
|
||||
expiresAt?: string
|
||||
user?: CurrentUser
|
||||
}
|
||||
|
||||
export type PagedList<T> = {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type UserSummaryDto = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
isActivated: boolean
|
||||
isBlocked: boolean
|
||||
activatedAt: string | null
|
||||
}
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
name: string
|
||||
maxConfigs: number
|
||||
isSystem: boolean
|
||||
}
|
||||
|
||||
export type ActivationRequestAdminDto = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
comment: string | null
|
||||
status: ActivationStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type NodeDto = {
|
||||
id: string
|
||||
name: string
|
||||
baseAddress: string
|
||||
username: string
|
||||
location: string | null
|
||||
status: NodeStatus
|
||||
isEnabled: boolean
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
export type NodeProbeResultDto = {
|
||||
isReachable: boolean
|
||||
errorMessage: string | null
|
||||
status: NodeStatus
|
||||
}
|
||||
|
||||
export type SyncNodeResultDto = {
|
||||
inboundsSynced: number
|
||||
status: NodeStatus
|
||||
}
|
||||
|
||||
export type InboundDto = {
|
||||
id: string
|
||||
nodeId: string
|
||||
remoteInboundId: string
|
||||
protocol: VpnProtocol
|
||||
remark: string
|
||||
port: number
|
||||
isPublished: boolean
|
||||
displayName: string | null
|
||||
maxClients: number | null
|
||||
allowedRoleIds: string[]
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
export type AdminAppDto = {
|
||||
id: string
|
||||
name: string
|
||||
downloadUrl: string
|
||||
operatingSystem: OsPlatform
|
||||
description: string | null
|
||||
iconUrl: string | null
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export type StatsDto = {
|
||||
totalUsers: number
|
||||
activatedUsers: number
|
||||
pendingActivationRequests: number
|
||||
totalNodes: number
|
||||
onlineNodes: number
|
||||
totalConfigs: number
|
||||
activeConfigs: number
|
||||
totalUsedUpBytes: number
|
||||
totalUsedDownBytes: number
|
||||
}
|
||||
|
||||
export type AuditSource = 'Web' | 'Telegram' | 'System'
|
||||
|
||||
export type AuditLogDto = {
|
||||
id: number
|
||||
actorId: string | null
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
metadata: string | null
|
||||
source: AuditSource
|
||||
createdAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user