From ea1e5bbf6a41dc7de9f7b2a2fa4fc32cd1caea3e Mon Sep 17 00:00:00 2001 From: SERTY Date: Tue, 3 Feb 2026 09:32:02 +0300 Subject: [PATCH] =?UTF-8?q?0302-=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BA=D1=83=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=BB=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82=D0=BE=D0=BF=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E.=20=D1=81=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D1=8F=D0=B9=D1=86=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B1=D0=BB=D0=B8=D0=B7=D0=BD=D0=B5=D1=86=D0=BE=D0=B2?= =?UTF-8?q?=20-=20desktop=20=D0=B8=20TMA,=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B8=D0=BB=20=D0=BA=20=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=D1=83=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kilocodemodes | 14 +++ cmd/main.go | 8 +- internal/domain/account/entity.go | 18 +++ internal/infrastructure/db/postgres.go | 1 + .../repository/account/postgres.go | 29 +++++ internal/services/auth/service.go | 82 +++++++++++++- internal/transport/http/handlers/auth.go | 77 ++++++++++--- internal/transport/http/middleware/auth.go | 70 +++++++++--- rmser-view/src/App.tsx | 5 +- .../src/components/layout/SessionGuard.tsx | 78 +++++++++++++ rmser-view/src/hooks/useWebSocket.ts | 99 +++++++++++++++-- .../layouts/DesktopLayout/DesktopHeader.tsx | 4 +- .../pages/desktop/auth/DesktopAuthScreen.tsx | 105 ++++++++++++------ rmser-view/src/services/api.ts | 43 +++++-- 14 files changed, 547 insertions(+), 86 deletions(-) create mode 100644 .kilocodemodes create mode 100644 rmser-view/src/components/layout/SessionGuard.tsx diff --git a/.kilocodemodes b/.kilocodemodes new file mode 100644 index 0000000..0be3cd5 --- /dev/null +++ b/.kilocodemodes @@ -0,0 +1,14 @@ +customModes: + - slug: frontend-specialist + name: Frontend Specialist + roleDefinition: | + You are a frontend developer expert in React, TypeScript, and modern CSS. You focus on creating intuitive user interfaces and excellent user experiences. + groups: + - read + - browser + - - edit + - fileRegex: \.(tsx?|jsx?|css|scss|less)$ + description: Frontend files only + customInstructions: | + Prioritize accessibility, responsive design, and performance. Use semantic HTML and follow React best practices. + source: project diff --git a/cmd/main.go b/cmd/main.go index e61a1cd..f8e85fb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -135,7 +135,7 @@ func main() { settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) settingsHandler.SetRMSFactory(rmsFactory) invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService) - authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername) + authHandler := handlers.NewAuthHandler(authService, accountRepo, cfg.Telegram.BotUsername) // 10. Telegram Bot (Передаем syncService и authService) if cfg.Telegram.Token != "" { @@ -176,8 +176,12 @@ func main() { // Хендлер инициализации desktop авторизации (без middleware) api.POST("/auth/init-desktop", authHandler.InitDesktopAuth) + // Новые endpoints для сессионной авторизации + api.POST("/auth/session", authHandler.CreateSession) + api.GET("/auth/me", authHandler.GetMe) + api.POST("/auth/logout", authHandler.Logout) - api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token, cfg.Security.SecretKey, cfg.App.MaintenanceMode, cfg.App.DevIDs)) + api.Use(middleware.AuthMiddleware(accountRepo, authService, cfg.Telegram.Token, cfg.Security.SecretKey, cfg.App.MaintenanceMode, cfg.App.DevIDs)) { // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go index 5be275d..8ed3b4b 100644 --- a/internal/domain/account/entity.go +++ b/internal/domain/account/entity.go @@ -88,6 +88,18 @@ type RMSServer struct { UpdatedAt time.Time `json:"updated_at"` } +// Session - Сессия пользователя для аутентификации +type Session struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + UserID uuid.UUID `gorm:"type:uuid;not null;index"` + RefreshToken string `gorm:"type:varchar(255);not null;index"` // Уникальный токен сессии + UserAgent string `gorm:"type:varchar(255)"` + IP string `gorm:"type:varchar(50)"` + ExpiresAt time.Time `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time // Для отслеживания sliding expiration +} + // Repository интерфейс type Repository interface { // Users @@ -151,4 +163,10 @@ type Repository interface { UpdateLastSync(serverID uuid.UUID) error // GetServersForSync возвращает серверы, готовые для синхронизации GetServersForSync(idleThreshold time.Duration) ([]RMSServer, error) + + // === Session Management === + CreateSession(session *Session) error + GetSessionByToken(token string) (*Session, error) + UpdateSessionExpiry(sessionID uuid.UUID, newExpiry time.Time) error + DeleteSession(token string) error } diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go index 6559b95..b21247b 100644 --- a/internal/infrastructure/db/postgres.go +++ b/internal/infrastructure/db/postgres.go @@ -53,6 +53,7 @@ func NewPostgresDB(dsn string) *gorm.DB { &account.User{}, &account.RMSServer{}, &account.ServerUser{}, + &account.Session{}, &billing.Order{}, &catalog.Product{}, &catalog.MeasureUnit{}, diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go index 9d6a6c1..3b7766c 100644 --- a/internal/infrastructure/repository/account/postgres.go +++ b/internal/infrastructure/repository/account/postgres.go @@ -552,3 +552,32 @@ func (r *pgRepository) SetMuteDraftNotifications(userID, serverID uuid.UUID, mut Where("user_id = ? AND server_id = ?", userID, serverID). Update("mute_draft_notifications", mute).Error } + +// === Session Management === + +// CreateSession создает новую сессию пользователя +func (r *pgRepository) CreateSession(session *account.Session) error { + return r.db.Create(session).Error +} + +// GetSessionByToken ищет сессию по токену +func (r *pgRepository) GetSessionByToken(token string) (*account.Session, error) { + var session account.Session + if err := r.db.Where("refresh_token = ?", token).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +// UpdateSessionExpiry обновляет время истечения сессии (sliding expiration) +func (r *pgRepository) UpdateSessionExpiry(sessionID uuid.UUID, newExpiry time.Time) error { + return r.db.Model(&account.Session{}).Where("id = ?", sessionID).Update("expires_at", newExpiry).Error +} + +// DeleteSession удаляет сессию по токену +func (r *pgRepository) DeleteSession(token string) error { + return r.db.Where("refresh_token = ?", token).Delete(&account.Session{}).Error +} diff --git a/internal/services/auth/service.go b/internal/services/auth/service.go index 8fe4be3..824c4a5 100644 --- a/internal/services/auth/service.go +++ b/internal/services/auth/service.go @@ -1,7 +1,10 @@ package auth import ( + "crypto/rand" + "encoding/hex" "errors" + "fmt" "time" "rmser/internal/domain/account" @@ -22,9 +25,9 @@ type Claims struct { // Service представляет сервис авторизации для desktop auth type Service struct { - accountRepo account.Repository - wsServer *ws.Server - secretKey string + accountRepo account.Repository + wsServer *ws.Server + secretKey string } // NewService создает новый экземпляр сервиса авторизации @@ -105,3 +108,76 @@ func (s *Service) generateJWTToken(user *account.User) (string, error) { return tokenString, nil } + +// generateRefreshToken генерирует безопасный refresh токен +func (s *Service) generateRefreshToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// CreateWebSession создает новую веб-сессию для пользователя +func (s *Service) CreateWebSession(userID uuid.UUID, userAgent, ip string) (*account.Session, error) { + token, err := s.generateRefreshToken() + if err != nil { + logger.Log.Error("failed to generate refresh token", zap.Error(err)) + return nil, err + } + + session := &account.Session{ + UserID: userID, + RefreshToken: token, + UserAgent: userAgent, + IP: ip, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + } + + if err := s.accountRepo.CreateSession(session); err != nil { + logger.Log.Error("failed to create session", zap.Error(err)) + return nil, err + } + + return session, nil +} + +// ValidateAndExtendSession проверяет и продлевает сессию (sliding window) +func (s *Service) ValidateAndExtendSession(token string) (*account.User, error) { + session, err := s.accountRepo.GetSessionByToken(token) + if err != nil { + logger.Log.Error("failed to get session", zap.Error(err)) + return nil, err + } + + if session == nil { + return nil, fmt.Errorf("session not found") + } + + if time.Now().After(session.ExpiresAt) { + // Сессия истекла, удаляем её + s.accountRepo.DeleteSession(token) + return nil, fmt.Errorf("session expired") + } + + // Sliding expiration: обновляем expiresAt на 7 дней от текущего момента + newExpiry := time.Now().Add(7 * 24 * time.Hour) + if err := s.accountRepo.UpdateSessionExpiry(session.ID, newExpiry); err != nil { + logger.Log.Error("failed to update session expiry", zap.Error(err)) + // Не критичная ошибка, продолжаем + } + + // Получаем пользователя + user, err := s.accountRepo.GetUserByID(session.UserID) + if err != nil { + logger.Log.Error("failed to get user", zap.Error(err)) + return nil, err + } + + return user, nil +} + +// DeleteSession удаляет сессию пользователя +func (s *Service) DeleteSession(token string) error { + return s.accountRepo.DeleteSession(token) +} diff --git a/internal/transport/http/handlers/auth.go b/internal/transport/http/handlers/auth.go index b988556..e9bc099 100644 --- a/internal/transport/http/handlers/auth.go +++ b/internal/transport/http/handlers/auth.go @@ -1,34 +1,33 @@ package handlers import ( - "fmt" "net/http" + "rmser/internal/domain/account" "rmser/internal/services/auth" - "rmser/pkg/logger" "github.com/gin-gonic/gin" - "go.uber.org/zap" + "github.com/google/uuid" ) // AuthHandler обрабатывает HTTP запросы авторизации type AuthHandler struct { - service *auth.Service - botUsername string + authService *auth.Service + accountService account.Repository + botUsername string } // NewAuthHandler создает новый экземпляр AuthHandler -func NewAuthHandler(s *auth.Service, botUsername string) *AuthHandler { - return &AuthHandler{service: s, botUsername: botUsername} +func NewAuthHandler(s *auth.Service, accountRepo account.Repository, botUsername string) *AuthHandler { + return &AuthHandler{authService: s, accountService: accountRepo, botUsername: botUsername} } // InitDesktopAuth инициализирует desktop авторизацию // POST /api/auth/init-desktop func (h *AuthHandler) InitDesktopAuth(c *gin.Context) { // Вызываем сервис для генерации session_id - sessionID, err := h.service.InitDesktopAuth() + sessionID, err := h.authService.InitDesktopAuth() if err != nil { - logger.Log.Error("Ошибка инициализации desktop авторизации", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Ошибка инициализации авторизации", }) @@ -36,12 +35,7 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) { } // Формируем QR URL для Telegram бота - qrURL := fmt.Sprintf("https://t.me/%s?start=auth_%s", h.botUsername, sessionID) - - logger.Log.Info("Desktop авторизация инициализирована", - zap.String("session_id", sessionID), - zap.String("qr_url", qrURL), - ) + qrURL := "https://t.me/" + h.botUsername + "?start=auth_" + sessionID // Возвращаем ответ с session_id и qr_url c.JSON(http.StatusOK, gin.H{ @@ -49,3 +43,56 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) { "qr_url": qrURL, }) } + +// CreateSession создает HTTP сессию и устанавливает HttpOnly Cookie +func (h *AuthHandler) CreateSession(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found in context"}) + return + } + + userAgent := c.Request.UserAgent() + ip := c.ClientIP() + + session, err := h.authService.CreateWebSession(userID.(uuid.UUID), userAgent, ip) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Устанавливаем HttpOnly Cookie на 7 дней + c.SetCookie("rmser_session", session.RefreshToken, 7*24*60*60, "/", "", false, true) + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// GetMe возвращает информацию о текущем пользователе +func (h *AuthHandler) GetMe(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + user, err := h.accountService.GetUserByID(userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, user) +} + +// Logout удаляет сессию и очищает cookie +func (h *AuthHandler) Logout(c *gin.Context) { + token, err := c.Cookie("rmser_session") + if err == nil && token != "" { + h.authService.DeleteSession(token) + } + + // Очищаем куку + c.SetCookie("rmser_session", "", -1, "/", "", false, true) + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go index 8aa6dec..6f6b1ba 100644 --- a/internal/transport/http/middleware/auth.go +++ b/internal/transport/http/middleware/auth.go @@ -26,27 +26,28 @@ type Claims struct { } // AuthMiddleware проверяет JWT токен (Desktop) или initData от Telegram -func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc { +func AuthMiddleware(accountRepo account.Repository, authService interface { + ValidateAndExtendSession(token string) (*account.User, error) +}, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc { return func(c *gin.Context) { - // 1. Извлекаем данные авторизации - authHeader := c.GetHeader("Authorization") - var authData string + // 1. Пробуем извлечь токен из Authorization header + tokenString := extractToken(c) - if strings.HasPrefix(authHeader, "Bearer ") { - authData = strings.TrimPrefix(authHeader, "Bearer ") - } else { - // Оставляем лазейку для отладки ТОЛЬКО если это не production режим - // В реальности лучше всегда требовать подпись - authData = c.Query("_auth") + // 2. Если нет токена в header, проверяем куку rmser_session + if tokenString == "" { + cookie, err := c.Cookie("rmser_session") + if err == nil && cookie != "" { + tokenString = cookie + } } - if authData == "" { + if tokenString == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"}) return } - // 2. Попытка 1: Проверяем JWT токен (Desktop) - token, err := jwt.ParseWithClaims(authData, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // 3. Попытка 1: Проверяем JWT токен (Desktop) + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"]) } @@ -78,15 +79,42 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey s } } - // 3. Попытка 2: Проверяем Telegram InitData - isValid, err := verifyTelegramInitData(authData, botToken) + // 4. Попытка 2: Проверяем сессию через authService (HttpOnly Cookie) + // Важно: проверяем длину, чтобы не слать initData (длинную строку) в БД как токен сессии + if authService != nil && len(tokenString) <= 128 { + user, err := authService.ValidateAndExtendSession(tokenString) + if err == nil && user != nil { + // Сессия валидна и продлена + if maintenanceMode { + isDev := false + for _, devID := range devIDs { + if user.TelegramID == devID { + isDev = true + break + } + } + if !isDev { + c.AbortWithStatusJSON(503, gin.H{"error": "maintenance_mode", "message": "Сервис на обслуживании"}) + return + } + } + + c.Set("userID", user.ID) + c.Set("telegramID", user.TelegramID) + c.Next() + return + } + } + + // 5. Попытка 3: Проверяем Telegram InitData + isValid, err := verifyTelegramInitData(tokenString, botToken) if !isValid || err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"}) return } // Извлекаем User ID из проверенных данных - values, _ := url.ParseQuery(authData) + values, _ := url.ParseQuery(tokenString) userJSON := values.Get("user") // Извлекаем id вручную из JSON-подобной строки или через простой парсинг @@ -125,6 +153,16 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey s } } +// extractToken извлекает токен из Authorization header +func extractToken(c *gin.Context) string { + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + // Оставляем лазейку для отладки ТОЛЬКО если это не production режим + return c.Query("_auth") +} + // verifyTelegramInitData реализует алгоритм проверки Telegram func verifyTelegramInitData(initData, token string) (bool, error) { values, err := url.ParseQuery(initData) diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index 2a26e7c..413845a 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { SessionGuard } from "./components/layout/SessionGuard"; import { BrowserRouter, Routes, @@ -211,7 +212,9 @@ function App() { return ( - + + + ); diff --git a/rmser-view/src/components/layout/SessionGuard.tsx b/rmser-view/src/components/layout/SessionGuard.tsx new file mode 100644 index 0000000..0cb221e --- /dev/null +++ b/rmser-view/src/components/layout/SessionGuard.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Spin } from "antd"; +import { api } from "../../services/api"; +import { useAuthStore } from "../../stores/authStore"; + +interface SessionGuardProps { + children: React.ReactNode; +} + +/** + * Компонент для проверки сессии при загрузке десктопного приложения. + * Если пользователь авторизован через куки - редиректит на dashboard. + * В Telegram Mini App проверка не требуется - сразу отдаём children. + */ +export function SessionGuard({ children }: SessionGuardProps) { + // Проверяем, находимся ли мы в Telegram - если да, пропускаем без проверки + const isTelegram = !!window.Telegram?.WebApp?.initData; + + const { isAuthenticated } = useAuthStore(); + const [isChecking, setIsChecking] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + // В Telegram проверка сессии не требуется + if (isTelegram) { + return; + } + + const checkSession = async () => { + // Если пользователь уже авторизован в store - пропускаем + if (isAuthenticated) { + setIsChecking(false); + return; + } + + try { + // Проверяем сессию через API + const user = await api.getMe(); + // Если успех - сохраняем в store и редиректим + useAuthStore.getState().setUser(user); + useAuthStore.getState().setToken("cookie"); + navigate("/web/dashboard", { replace: true }); + } catch { + // 401 - нет сессии, остаёмся на странице входа + // Другие ошибки - тоже остаёмся + } finally { + setIsChecking(false); + } + }; + + checkSession(); + }, [isTelegram, isAuthenticated, navigate]); + + // В Telegram сразу пропускаем + if (isTelegram) { + return <>{children}; + } + + // Если проверяем - показываем лоадер + if (isChecking) { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/rmser-view/src/hooks/useWebSocket.ts b/rmser-view/src/hooks/useWebSocket.ts index ec785b4..1aca2d8 100644 --- a/rmser-view/src/hooks/useWebSocket.ts +++ b/rmser-view/src/hooks/useWebSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; const apiUrl = import.meta.env.VITE_API_URL || ''; @@ -23,18 +23,98 @@ interface WsEvent { data: unknown; } -export const useWebSocket = (sessionId: string | null) => { +interface UseWebSocketParams { + autoReconnect?: boolean; + onDisconnect?: () => void; +} + +export const useWebSocket = (sessionId: string | null, params: UseWebSocketParams = {}) => { + const { autoReconnect = false, onDisconnect } = params; const [isConnected, setIsConnected] = useState(false); const [lastError, setLastError] = useState(null); const [lastMessage, setLastMessage] = useState(null); const wsRef = useRef(null); - + const reconnectTimeoutRef = useRef | null>(null); + const reconnectAttemptsRef = useRef(0); + const currentSessionIdRef = useRef(null); + + // Используем ref для хранения функции reconnect, чтобы она могла вызывать сама себя + const reconnectRef = useRef<(() => void) | null>(null); + + // Функция для переподключения WebSocket + const reconnect = useCallback(() => { + if (!currentSessionIdRef.current) return; + + const delay = Math.min(1000 + reconnectAttemptsRef.current * 1000, 5000); + reconnectAttemptsRef.current++; + + console.log(`🔄 WS Reconnect attempt ${reconnectAttemptsRef.current} in ${delay}ms`); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + const url = `${getWsUrl()}?session_id=${currentSessionIdRef.current}`; + console.log('🔌 Reconnecting WS:', url); + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log('✅ WS Reconnected'); + setIsConnected(true); + setLastError(null); + reconnectAttemptsRef.current = 0; + }; + + ws.onclose = () => { + // Используем ref для вызова reconnect + reconnectRef.current?.(); + }; + + ws.onerror = (error) => { + console.error('❌ WS Error', error); + setLastError('Connection error'); + setIsConnected(false); + }; + + ws.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data); + console.log('📨 WS Message:', parsed); + setLastMessage(parsed); + } catch { + console.error('Failed to parse WS message', event.data); + } + }; + }, delay); + }, []); + + const handleDisconnect = useCallback(() => { + if (onDisconnect) { + onDisconnect(); + } + + if (autoReconnect) { + reconnect(); + } + }, [onDisconnect, reconnect, autoReconnect]); + + useEffect(() => { + // Сохраняем reconnect в ref после его создания + reconnectRef.current = reconnect; + }, [reconnect]); + useEffect(() => { if (!sessionId) return; - + + currentSessionIdRef.current = sessionId; + reconnectAttemptsRef.current = 0; + const url = `${getWsUrl()}?session_id=${sessionId}`; - console.log('🔌 Connecting Native WS:', url); + console.log('🔌 Connecting WS:', url); const ws = new WebSocket(url); wsRef.current = ws; @@ -48,6 +128,7 @@ export const useWebSocket = (sessionId: string | null) => { ws.onclose = (event) => { console.log('⚠️ WS Closed', event.code, event.reason); setIsConnected(false); + handleDisconnect(); }; ws.onerror = (error) => { @@ -68,9 +149,13 @@ export const useWebSocket = (sessionId: string | null) => { return () => { console.log('🧹 WS Cleanup'); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } ws.close(); }; - }, [sessionId]); - + }, [sessionId, handleDisconnect]); + return { isConnected, lastError, lastMessage }; }; diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx index fb5a047..82b84c9 100644 --- a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx +++ b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx @@ -3,6 +3,7 @@ import { Layout, Space, Avatar, Dropdown, Select } from "antd"; import { UserOutlined, LogoutOutlined } from "@ant-design/icons"; import { useAuthStore } from "../../stores/authStore"; import { useServerStore } from "../../stores/serverStore"; +import { api } from "../../services/api"; const { Header } = Layout; @@ -20,7 +21,8 @@ export const DesktopHeader: React.FC = () => { fetchServers(); }, [fetchServers]); - const handleLogout = () => { + const handleLogout = async () => { + await api.logout(); logout(); window.location.href = "/web"; }; diff --git a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx index aa58a2f..e2dfec7 100644 --- a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx +++ b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { Card, Typography, Spin, Alert, message, Button } from "antd"; import { QRCodeSVG } from "qrcode.react"; -import { SendOutlined } from "@ant-design/icons"; +import { SendOutlined, ReloadOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; import { useWebSocket } from "../../../hooks/useWebSocket"; import { useAuthStore } from "../../../stores/authStore"; @@ -22,51 +22,76 @@ interface AuthSuccessData { /** * Экран авторизации для десктопной версии * Отображает QR код для авторизации через мобильное приложение + * Реализует самовосстановление при разрыве соединения */ export const DesktopAuthScreen: React.FC = () => { const navigate = useNavigate(); const { setToken, setUser } = useAuthStore(); const [sessionId, setSessionId] = useState(null); const [qrLink, setQrLink] = useState(null); - const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); - const { isConnected, lastError, lastMessage } = useWebSocket(sessionId); + // Функция обновления сессии QR + const refreshSession = useCallback(async () => { + try { + setIsRefreshing(true); + setError(null); + + const data = await api.initDesktopAuth(); + setSessionId(data.session_id); + setQrLink(data.qr_url); + console.log("🔄 Session refreshed:", data.session_id); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Неизвестная ошибка при обновлении QR"; + setError(errorMessage); + console.error("❌ Refresh error:", err); + } finally { + setIsRefreshing(false); + } + }, []); + + // Обработка разрыва соединения - автообновление QR + const handleDisconnect = useCallback(() => { + console.log("⚠️ WebSocket disconnected, refreshing QR..."); + refreshSession(); + }, [refreshSession]); + + // Инициализация WebSocket с отключенным автореконнектом + const { isConnected, lastError, lastMessage } = useWebSocket(sessionId, { + autoReconnect: false, + onDisconnect: handleDisconnect, + }); // Инициализация сессии авторизации при маунте useEffect(() => { - const initAuth = async () => { - try { - setLoading(true); - setError(null); - - const data = await api.initDesktopAuth(); - setSessionId(data.session_id); - setQrLink(data.qr_url); - } catch (err) { - setError(err instanceof Error ? err.message : "Неизвестная ошибка"); - } finally { - setLoading(false); - } - }; - - initAuth(); - }, []); + refreshSession(); + }, [refreshSession]); // Обработка события успешной авторизации через WebSocket useEffect(() => { if (lastMessage && lastMessage.event === "auth_success") { - const data = lastMessage.data as AuthSuccessData; - const { token, user } = data; - console.log("🎉 Auth Success:", user); - setToken(token); - setUser(user); - message.success("Вход выполнен!"); - navigate("/web/dashboard"); + const handleAuthSuccess = async () => { + const data = lastMessage.data as AuthSuccessData; + const { token, user } = data; + console.log("🎉 Auth Success:", user); + + // Создаём сессию на сервере (установка HttpOnly куки) + await api.createSession(); + + // Устанавливаем токен и данные пользователя + setToken(token); + setUser(user); + message.success("Вход выполнен!"); + navigate("/web/dashboard"); + }; + + handleAuthSuccess(); } }, [lastMessage, setToken, setUser, navigate]); - if (loading) { + // Отображение лоадера при обновлении QR + if (isRefreshing) { return (
{ backgroundColor: "#f0f2f5", }} > - +
); } @@ -100,6 +128,11 @@ export const DesktopAuthScreen: React.FC = () => { type="error" showIcon style={{ maxWidth: "400px" }} + action={ + + } /> ); @@ -169,9 +202,19 @@ export const DesktopAuthScreen: React.FC = () => { Disconnected )}

-

Session: {sessionId}

+

Session: {sessionId?.slice(0, 8)}...

{lastError &&

Error: {lastError}

} + + {/* Кнопка ручного обновления QR */} + ); diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index 23d865e..36ba940 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -1,6 +1,14 @@ import axios from 'axios'; import { notification } from 'antd'; import { useAuthStore } from '../stores/authStore'; + +// Тип пользователя для сессионной авторизации +export interface User { + id: string; + username: string; + email?: string; + role?: string; +} import type { CatalogItem, CreateInvoiceRequest, @@ -64,30 +72,31 @@ export const apiClient = axios.create({ headers: { 'Content-Type': 'application/json', }, + withCredentials: true, }); -// --- Request Interceptor (Авторизация через JWT или initData) --- +// --- Request Interceptor (Авторизация через initData или JWT) --- apiClient.interceptors.request.use((config) => { // Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации if (config.url?.endsWith('/auth/init-desktop')) { return config; } - // Шаг 2: Desktop Auth - проверяем JWT токен из authStore - const jwtToken = useAuthStore.getState().token; - if (jwtToken) { - config.headers['Authorization'] = `Bearer ${jwtToken}`; - return config; - } - - // Шаг 3: Mobile Auth - проверяем Telegram initData + // Шаг 2: Mobile Auth (Telegram Mini App) - проверяем Telegram initData ПЕРВЫМ (высший приоритет) const initData = tg?.initData; if (initData) { config.headers['Authorization'] = `Bearer ${initData}`; return config; } - // Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос + // Шаг 3: Desktop Auth - проверяем JWT токен из authStore + const jwtToken = useAuthStore.getState().token; + if (jwtToken) { + config.headers['Authorization'] = `Bearer ${jwtToken}`; + return config; + } + + // Шаг 4: Block - если нет ни initData, ни JWT, отклоняем запрос console.error('Запрос заблокирован: отсутствуют данные авторизации.'); return Promise.reject(new Error('MISSING_AUTH')); }); @@ -328,6 +337,20 @@ export const api = { return data; }, + // Сессионная авторизация (Desktop через куки) + createSession: async (): Promise => { + await apiClient.post('/auth/session'); + }, + + getMe: async (): Promise => { + const { data } = await apiClient.get('/auth/me'); + return data; + }, + + logout: async (): Promise => { + await apiClient.post('/auth/logout'); + }, + // --- Управление серверами --- getUserServers: async (): Promise => {