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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
||||
&account.User{},
|
||||
&account.RMSServer{},
|
||||
&account.ServerUser{},
|
||||
&account.Session{},
|
||||
&billing.Order{},
|
||||
&catalog.Product{},
|
||||
&catalog.MeasureUnit{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user