mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
0302-добавил куки и сломал десктоп авторизацию.
сложно поддерживать однояйцевых близнецов - desktop и TMA, подготовил к рефакторингу структуры
This commit is contained in:
14
.kilocodemodes
Normal file
14
.kilocodemodes
Normal file
@@ -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
|
||||||
@@ -135,7 +135,7 @@ func main() {
|
|||||||
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
||||||
settingsHandler.SetRMSFactory(rmsFactory)
|
settingsHandler.SetRMSFactory(rmsFactory)
|
||||||
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
|
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)
|
// 10. Telegram Bot (Передаем syncService и authService)
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
@@ -176,8 +176,12 @@ func main() {
|
|||||||
|
|
||||||
// Хендлер инициализации desktop авторизации (без middleware)
|
// Хендлер инициализации desktop авторизации (без middleware)
|
||||||
api.POST("/auth/init-desktop", authHandler.InitDesktopAuth)
|
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
|
// Drafts & Invoices
|
||||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ type RMSServer struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 интерфейс
|
// Repository интерфейс
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
// Users
|
// Users
|
||||||
@@ -151,4 +163,10 @@ type Repository interface {
|
|||||||
UpdateLastSync(serverID uuid.UUID) error
|
UpdateLastSync(serverID uuid.UUID) error
|
||||||
// GetServersForSync возвращает серверы, готовые для синхронизации
|
// GetServersForSync возвращает серверы, готовые для синхронизации
|
||||||
GetServersForSync(idleThreshold time.Duration) ([]RMSServer, error)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
|||||||
&account.User{},
|
&account.User{},
|
||||||
&account.RMSServer{},
|
&account.RMSServer{},
|
||||||
&account.ServerUser{},
|
&account.ServerUser{},
|
||||||
|
&account.Session{},
|
||||||
&billing.Order{},
|
&billing.Order{},
|
||||||
&catalog.Product{},
|
&catalog.Product{},
|
||||||
&catalog.MeasureUnit{},
|
&catalog.MeasureUnit{},
|
||||||
|
|||||||
@@ -552,3 +552,32 @@ func (r *pgRepository) SetMuteDraftNotifications(userID, serverID uuid.UUID, mut
|
|||||||
Where("user_id = ? AND server_id = ?", userID, serverID).
|
Where("user_id = ? AND server_id = ?", userID, serverID).
|
||||||
Update("mute_draft_notifications", mute).Error
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
@@ -22,9 +25,9 @@ type Claims struct {
|
|||||||
|
|
||||||
// Service представляет сервис авторизации для desktop auth
|
// Service представляет сервис авторизации для desktop auth
|
||||||
type Service struct {
|
type Service struct {
|
||||||
accountRepo account.Repository
|
accountRepo account.Repository
|
||||||
wsServer *ws.Server
|
wsServer *ws.Server
|
||||||
secretKey string
|
secretKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService создает новый экземпляр сервиса авторизации
|
// NewService создает новый экземпляр сервиса авторизации
|
||||||
@@ -105,3 +108,76 @@ func (s *Service) generateJWTToken(user *account.User) (string, error) {
|
|||||||
|
|
||||||
return tokenString, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/services/auth"
|
"rmser/internal/services/auth"
|
||||||
"rmser/pkg/logger"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler обрабатывает HTTP запросы авторизации
|
// AuthHandler обрабатывает HTTP запросы авторизации
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
service *auth.Service
|
authService *auth.Service
|
||||||
botUsername string
|
accountService account.Repository
|
||||||
|
botUsername string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler создает новый экземпляр AuthHandler
|
// NewAuthHandler создает новый экземпляр AuthHandler
|
||||||
func NewAuthHandler(s *auth.Service, botUsername string) *AuthHandler {
|
func NewAuthHandler(s *auth.Service, accountRepo account.Repository, botUsername string) *AuthHandler {
|
||||||
return &AuthHandler{service: s, botUsername: botUsername}
|
return &AuthHandler{authService: s, accountService: accountRepo, botUsername: botUsername}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDesktopAuth инициализирует desktop авторизацию
|
// InitDesktopAuth инициализирует desktop авторизацию
|
||||||
// POST /api/auth/init-desktop
|
// POST /api/auth/init-desktop
|
||||||
func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
|
func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
|
||||||
// Вызываем сервис для генерации session_id
|
// Вызываем сервис для генерации session_id
|
||||||
sessionID, err := h.service.InitDesktopAuth()
|
sessionID, err := h.authService.InitDesktopAuth()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Ошибка инициализации desktop авторизации", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Ошибка инициализации авторизации",
|
"error": "Ошибка инициализации авторизации",
|
||||||
})
|
})
|
||||||
@@ -36,12 +35,7 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Формируем QR URL для Telegram бота
|
// Формируем QR URL для Telegram бота
|
||||||
qrURL := fmt.Sprintf("https://t.me/%s?start=auth_%s", h.botUsername, sessionID)
|
qrURL := "https://t.me/" + h.botUsername + "?start=auth_" + sessionID
|
||||||
|
|
||||||
logger.Log.Info("Desktop авторизация инициализирована",
|
|
||||||
zap.String("session_id", sessionID),
|
|
||||||
zap.String("qr_url", qrURL),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Возвращаем ответ с session_id и qr_url
|
// Возвращаем ответ с session_id и qr_url
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -49,3 +43,56 @@ func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
|
|||||||
"qr_url": qrURL,
|
"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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,27 +26,28 @@ type Claims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AuthMiddleware проверяет JWT токен (Desktop) или initData от Telegram
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
// 1. Извлекаем данные авторизации
|
// 1. Пробуем извлечь токен из Authorization header
|
||||||
authHeader := c.GetHeader("Authorization")
|
tokenString := extractToken(c)
|
||||||
var authData string
|
|
||||||
|
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
// 2. Если нет токена в header, проверяем куку rmser_session
|
||||||
authData = strings.TrimPrefix(authHeader, "Bearer ")
|
if tokenString == "" {
|
||||||
} else {
|
cookie, err := c.Cookie("rmser_session")
|
||||||
// Оставляем лазейку для отладки ТОЛЬКО если это не production режим
|
if err == nil && cookie != "" {
|
||||||
// В реальности лучше всегда требовать подпись
|
tokenString = cookie
|
||||||
authData = c.Query("_auth")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authData == "" {
|
if tokenString == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Попытка 1: Проверяем JWT токен (Desktop)
|
// 3. Попытка 1: Проверяем JWT токен (Desktop)
|
||||||
token, err := jwt.ParseWithClaims(authData, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"])
|
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
|
// 4. Попытка 2: Проверяем сессию через authService (HttpOnly Cookie)
|
||||||
isValid, err := verifyTelegramInitData(authData, botToken)
|
// Важно: проверяем длину, чтобы не слать 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 {
|
if !isValid || err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем User ID из проверенных данных
|
// Извлекаем User ID из проверенных данных
|
||||||
values, _ := url.ParseQuery(authData)
|
values, _ := url.ParseQuery(tokenString)
|
||||||
userJSON := values.Get("user")
|
userJSON := values.Get("user")
|
||||||
|
|
||||||
// Извлекаем id вручную из JSON-подобной строки или через простой парсинг
|
// Извлекаем 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
|
// verifyTelegramInitData реализует алгоритм проверки Telegram
|
||||||
func verifyTelegramInitData(initData, token string) (bool, error) {
|
func verifyTelegramInitData(initData, token string) (bool, error) {
|
||||||
values, err := url.ParseQuery(initData)
|
values, err := url.ParseQuery(initData)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { SessionGuard } from "./components/layout/SessionGuard";
|
||||||
import {
|
import {
|
||||||
BrowserRouter,
|
BrowserRouter,
|
||||||
Routes,
|
Routes,
|
||||||
@@ -211,7 +212,9 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppContent />
|
<SessionGuard>
|
||||||
|
<AppContent />
|
||||||
|
</SessionGuard>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Providers>
|
</Providers>
|
||||||
);
|
);
|
||||||
|
|||||||
78
rmser-view/src/components/layout/SessionGuard.tsx
Normal file
78
rmser-view/src/components/layout/SessionGuard.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" tip="Проверка сессии..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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 || '';
|
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
@@ -23,18 +23,98 @@ interface WsEvent {
|
|||||||
data: unknown;
|
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 [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastError, setLastError] = useState<string | null>(null);
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
const [lastMessage, setLastMessage] = useState<WsEvent | null>(null);
|
const [lastMessage, setLastMessage] = useState<WsEvent | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const currentSessionIdRef = useRef<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
currentSessionIdRef.current = sessionId;
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
|
||||||
const url = `${getWsUrl()}?session_id=${sessionId}`;
|
const url = `${getWsUrl()}?session_id=${sessionId}`;
|
||||||
console.log('🔌 Connecting Native WS:', url);
|
console.log('🔌 Connecting WS:', url);
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
@@ -48,6 +128,7 @@ export const useWebSocket = (sessionId: string | null) => {
|
|||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
console.log('⚠️ WS Closed', event.code, event.reason);
|
console.log('⚠️ WS Closed', event.code, event.reason);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
handleDisconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
@@ -68,9 +149,13 @@ export const useWebSocket = (sessionId: string | null) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('🧹 WS Cleanup');
|
console.log('🧹 WS Cleanup');
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId, handleDisconnect]);
|
||||||
|
|
||||||
return { isConnected, lastError, lastMessage };
|
return { isConnected, lastError, lastMessage };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
|||||||
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useServerStore } from "../../stores/serverStore";
|
import { useServerStore } from "../../stores/serverStore";
|
||||||
|
import { api } from "../../services/api";
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ export const DesktopHeader: React.FC = () => {
|
|||||||
fetchServers();
|
fetchServers();
|
||||||
}, [fetchServers]);
|
}, [fetchServers]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
|
await api.logout();
|
||||||
logout();
|
logout();
|
||||||
window.location.href = "/web";
|
window.location.href = "/web";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { Card, Typography, Spin, Alert, message, Button } from "antd";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { useWebSocket } from "../../../hooks/useWebSocket";
|
import { useWebSocket } from "../../../hooks/useWebSocket";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
@@ -22,51 +22,76 @@ interface AuthSuccessData {
|
|||||||
/**
|
/**
|
||||||
* Экран авторизации для десктопной версии
|
* Экран авторизации для десктопной версии
|
||||||
* Отображает QR код для авторизации через мобильное приложение
|
* Отображает QR код для авторизации через мобильное приложение
|
||||||
|
* Реализует самовосстановление при разрыве соединения
|
||||||
*/
|
*/
|
||||||
export const DesktopAuthScreen: React.FC = () => {
|
export const DesktopAuthScreen: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setToken, setUser } = useAuthStore();
|
const { setToken, setUser } = useAuthStore();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [qrLink, setQrLink] = useState<string | null>(null);
|
const [qrLink, setQrLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
refreshSession();
|
||||||
try {
|
}, [refreshSession]);
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Обработка события успешной авторизации через WebSocket
|
// Обработка события успешной авторизации через WebSocket
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastMessage && lastMessage.event === "auth_success") {
|
if (lastMessage && lastMessage.event === "auth_success") {
|
||||||
const data = lastMessage.data as AuthSuccessData;
|
const handleAuthSuccess = async () => {
|
||||||
const { token, user } = data;
|
const data = lastMessage.data as AuthSuccessData;
|
||||||
console.log("🎉 Auth Success:", user);
|
const { token, user } = data;
|
||||||
setToken(token);
|
console.log("🎉 Auth Success:", user);
|
||||||
setUser(user);
|
|
||||||
message.success("Вход выполнен!");
|
// Создаём сессию на сервере (установка HttpOnly куки)
|
||||||
navigate("/web/dashboard");
|
await api.createSession();
|
||||||
|
|
||||||
|
// Устанавливаем токен и данные пользователя
|
||||||
|
setToken(token);
|
||||||
|
setUser(user);
|
||||||
|
message.success("Вход выполнен!");
|
||||||
|
navigate("/web/dashboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAuthSuccess();
|
||||||
}
|
}
|
||||||
}, [lastMessage, setToken, setUser, navigate]);
|
}, [lastMessage, setToken, setUser, navigate]);
|
||||||
|
|
||||||
if (loading) {
|
// Отображение лоадера при обновлении QR
|
||||||
|
if (isRefreshing) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -77,7 +102,10 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
backgroundColor: "#f0f2f5",
|
backgroundColor: "#f0f2f5",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Spin size="large" tip="Загрузка..." />
|
<Spin
|
||||||
|
size="large"
|
||||||
|
tip="Обновление QR..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,6 +128,11 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ maxWidth: "400px" }}
|
style={{ maxWidth: "400px" }}
|
||||||
|
action={
|
||||||
|
<Button size="small" type="primary" danger onClick={refreshSession}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -169,9 +202,19 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
<span style={{ color: "red" }}>Disconnected</span>
|
<span style={{ color: "red" }}>Disconnected</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>Session: {sessionId}</p>
|
<p>Session: {sessionId?.slice(0, 8)}...</p>
|
||||||
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка ручного обновления QR */}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={refreshSession}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
>
|
||||||
|
Обновить QR
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { notification } from 'antd';
|
import { notification } from 'antd';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
// Тип пользователя для сессионной авторизации
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
import type {
|
import type {
|
||||||
CatalogItem,
|
CatalogItem,
|
||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
@@ -64,30 +72,31 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Request Interceptor (Авторизация через JWT или initData) ---
|
// --- Request Interceptor (Авторизация через initData или JWT) ---
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
// Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
|
// Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
|
||||||
if (config.url?.endsWith('/auth/init-desktop')) {
|
if (config.url?.endsWith('/auth/init-desktop')) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 2: Desktop Auth - проверяем JWT токен из authStore
|
// Шаг 2: Mobile Auth (Telegram Mini App) - проверяем Telegram initData ПЕРВЫМ (высший приоритет)
|
||||||
const jwtToken = useAuthStore.getState().token;
|
|
||||||
if (jwtToken) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${jwtToken}`;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 3: Mobile Auth - проверяем Telegram initData
|
|
||||||
const initData = tg?.initData;
|
const initData = tg?.initData;
|
||||||
if (initData) {
|
if (initData) {
|
||||||
config.headers['Authorization'] = `Bearer ${initData}`;
|
config.headers['Authorization'] = `Bearer ${initData}`;
|
||||||
return config;
|
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('Запрос заблокирован: отсутствуют данные авторизации.');
|
console.error('Запрос заблокирован: отсутствуют данные авторизации.');
|
||||||
return Promise.reject(new Error('MISSING_AUTH'));
|
return Promise.reject(new Error('MISSING_AUTH'));
|
||||||
});
|
});
|
||||||
@@ -328,6 +337,20 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Сессионная авторизация (Desktop через куки)
|
||||||
|
createSession: async (): Promise<void> => {
|
||||||
|
await apiClient.post('/auth/session');
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async (): Promise<User> => {
|
||||||
|
const { data } = await apiClient.get<User>('/auth/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
await apiClient.post('/auth/logout');
|
||||||
|
},
|
||||||
|
|
||||||
// --- Управление серверами ---
|
// --- Управление серверами ---
|
||||||
|
|
||||||
getUserServers: async (): Promise<ServerShort[]> => {
|
getUserServers: async (): Promise<ServerShort[]> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user