2801-есть десктоп-версия. реализован ws для авторизации через тг-бота

This commit is contained in:
2026-01-28 08:12:41 +03:00
parent a536b3ff3c
commit b99e328d35
26 changed files with 2258 additions and 82 deletions

View File

@@ -0,0 +1,107 @@
package auth
import (
"errors"
"time"
"rmser/internal/domain/account"
"rmser/internal/transport/ws"
"rmser/pkg/logger"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.uber.org/zap"
)
// Claims представляет JWT claims для токена авторизации
type Claims struct {
UserID uuid.UUID `json:"user_id"`
TelegramID int64 `json:"telegram_id"`
jwt.RegisteredClaims
}
// Service представляет сервис авторизации для desktop auth
type Service struct {
accountRepo account.Repository
wsServer *ws.Server
secretKey string
}
// NewService создает новый экземпляр сервиса авторизации
func NewService(accountRepo account.Repository, wsServer *ws.Server, secretKey string) *Service {
return &Service{
accountRepo: accountRepo,
wsServer: wsServer,
secretKey: secretKey,
}
}
// InitDesktopAuth генерирует уникальный session_id для desktop авторизации
func (s *Service) InitDesktopAuth() (string, error) {
sessionID := uuid.New().String()
logger.Log.Info("Инициализация desktop авторизации",
zap.String("session_id", sessionID),
)
return sessionID, nil
}
// ConfirmDesktopAuth подтверждает авторизацию и отправляет токен через Socket.IO
func (s *Service) ConfirmDesktopAuth(sessionID string, telegramID int64) error {
// Ищем пользователя по Telegram ID
user, err := s.accountRepo.GetUserByTelegramID(telegramID)
if err != nil {
logger.Log.Error("Пользователь не найден",
zap.Int64("telegram_id", telegramID),
zap.Error(err),
)
return errors.New("пользователь не найден")
}
// Генерируем JWT токен
token, err := s.generateJWTToken(user)
if err != nil {
logger.Log.Error("Ошибка генерации JWT токена",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
return err
}
// Отправляем токен через WebSocket
s.wsServer.SendAuthSuccess(sessionID, token, *user)
logger.Log.Info("Desktop авторизация подтверждена",
zap.String("session_id", sessionID),
zap.String("user_id", user.ID.String()),
zap.Int64("telegram_id", telegramID),
)
return nil
}
// generateJWTToken генерирует JWT токен для пользователя
func (s *Service) generateJWTToken(user *account.User) (string, error) {
// Создаем claims с данными пользователя
claims := Claims{
UserID: user.ID,
TelegramID: user.TelegramID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Токен действителен 24 часа
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
// Создаем токен с claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Подписываем токен секретным ключом
tokenString, err := token.SignedString([]byte(s.secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}

View File

@@ -0,0 +1,51 @@
package handlers
import (
"fmt"
"net/http"
"rmser/internal/services/auth"
"rmser/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AuthHandler обрабатывает HTTP запросы авторизации
type AuthHandler struct {
service *auth.Service
botUsername string
}
// NewAuthHandler создает новый экземпляр AuthHandler
func NewAuthHandler(s *auth.Service, botUsername string) *AuthHandler {
return &AuthHandler{service: s, botUsername: botUsername}
}
// InitDesktopAuth инициализирует desktop авторизацию
// POST /api/auth/init-desktop
func (h *AuthHandler) InitDesktopAuth(c *gin.Context) {
// Вызываем сервис для генерации session_id
sessionID, err := h.service.InitDesktopAuth()
if err != nil {
logger.Log.Error("Ошибка инициализации desktop авторизации", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Ошибка инициализации авторизации",
})
return
}
// Формируем 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),
)
// Возвращаем ответ с session_id и qr_url
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"qr_url": qrURL,
})
}

View File

@@ -14,37 +14,79 @@ import (
"rmser/internal/domain/account"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthMiddleware проверяет initData от Telegram
func AuthMiddleware(accountRepo account.Repository, botToken string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {
// Claims представляет JWT claims для токена авторизации
type Claims struct {
UserID uuid.UUID `json:"user_id"`
TelegramID int64 `json:"telegram_id"`
jwt.RegisteredClaims
}
// AuthMiddleware проверяет JWT токен (Desktop) или initData от Telegram
func AuthMiddleware(accountRepo account.Repository, botToken string, secretKey string, maintenanceMode bool, devIDs []int64) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. Извлекаем данные авторизации
authHeader := c.GetHeader("Authorization")
var initData string
var authData string
if strings.HasPrefix(authHeader, "Bearer ") {
initData = strings.TrimPrefix(authHeader, "Bearer ")
authData = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// Оставляем лазейку для отладки ТОЛЬКО если это не production режим
// В реальности лучше всегда требовать подпись
initData = c.Query("_auth")
authData = c.Query("_auth")
}
if initData == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"})
if authData == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует токен или подпись"})
return
}
// 2. Проверяем подпись (HMAC-SHA256)
isValid, err := verifyTelegramInitData(initData, botToken)
// 2. Попытка 1: Проверяем JWT токен (Desktop)
token, err := jwt.ParseWithClaims(authData, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
if err == nil && token.Valid {
// JWT токен валиден
if claims, ok := token.Claims.(*Claims); ok {
// Проверка режима обслуживания: если включен, разрешаем доступ только разработчикам
if maintenanceMode {
isDev := false
for _, devID := range devIDs {
if claims.TelegramID == devID {
isDev = true
break
}
}
if !isDev {
c.AbortWithStatusJSON(503, gin.H{"error": "maintenance_mode", "message": "Сервис на обслуживании"})
return
}
}
c.Set("userID", claims.UserID)
c.Set("telegramID", claims.TelegramID)
c.Next()
return
}
}
// 3. Попытка 2: Проверяем Telegram InitData
isValid, err := verifyTelegramInitData(authData, botToken)
if !isValid || err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: поддельная подпись"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: недействительный токен или подпись"})
return
}
// 3. Извлекаем User ID из проверенных данных
values, _ := url.ParseQuery(initData)
// Извлекаем User ID из проверенных данных
values, _ := url.ParseQuery(authData)
userJSON := values.Get("user")
// Извлекаем id вручную из JSON-подобной строки или через простой парсинг
@@ -70,7 +112,7 @@ func AuthMiddleware(accountRepo account.Repository, botToken string, maintenance
}
}
// 4. Ищем пользователя в БД
// Ищем пользователя в БД
user, err := accountRepo.GetUserByTelegramID(tgID)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."})

View File

@@ -19,6 +19,7 @@ import (
"rmser/config"
"rmser/internal/domain/account"
"rmser/internal/infrastructure/rms"
"rmser/internal/services/auth"
"rmser/internal/services/billing"
draftsService "rmser/internal/services/drafts"
"rmser/internal/services/ocr"
@@ -47,6 +48,7 @@ type Bot struct {
rmsFactory *rms.Factory
cryptoManager *crypto.CryptoManager
draftsService *draftsService.Service
authService *auth.Service
draftEditor *DraftEditor
fsm *StateManager
@@ -69,6 +71,7 @@ func NewBot(
rmsFactory *rms.Factory,
cryptoManager *crypto.CryptoManager,
draftsService *draftsService.Service,
authService *auth.Service,
maintenanceMode bool,
devIDs []int64,
) (*Bot, error) {
@@ -105,6 +108,7 @@ func NewBot(
rmsFactory: rmsFactory,
cryptoManager: cryptoManager,
draftsService: draftsService,
authService: authService,
fsm: NewStateManager(),
adminIDs: admins,
devIDs: devs,
@@ -198,6 +202,29 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
func (bot *Bot) handleStartCommand(c tele.Context) error {
payload := c.Message().Payload
// Обработка desktop авторизации
if payload != "" && strings.HasPrefix(payload, "auth_") {
sessionID := strings.TrimPrefix(payload, "auth_")
telegramID := c.Sender().ID
logger.Log.Info("Обработка desktop авторизации",
zap.String("session_id", sessionID),
zap.Int64("telegram_id", telegramID),
)
if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil {
logger.Log.Error("Ошибка подтверждения desktop авторизации",
zap.String("session_id", sessionID),
zap.Int64("telegram_id", telegramID),
zap.Error(err),
)
return c.Send("❌ Ошибка авторизации. Попробуйте снова.", tele.ModeHTML)
}
return c.Send("✅ Авторизация успешна! Вы можете вернуться в приложение.", tele.ModeHTML)
}
if payload != "" && strings.HasPrefix(payload, "invite_") {
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
}

View File

@@ -0,0 +1,129 @@
package ws
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"rmser/internal/domain/account"
"rmser/pkg/logger"
)
type Server struct {
clients map[string]*websocket.Conn // session_id -> conn
register chan *clientParams
unregister chan string
mu sync.RWMutex
upgrader websocket.Upgrader
}
type clientParams struct {
sessionID string
conn *websocket.Conn
}
func NewServer() *Server {
return &Server{
clients: make(map[string]*websocket.Conn),
register: make(chan *clientParams),
unregister: make(chan string),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // Allow All CORS
},
}
}
func (s *Server) Run() {
for {
select {
case params := <-s.register:
s.mu.Lock()
// Если уже есть соединение с таким ID, закрываем старое
if old, ok := s.clients[params.sessionID]; ok {
old.Close()
}
s.clients[params.sessionID] = params.conn
s.mu.Unlock()
logger.Log.Info("WS Client connected", zap.String("session_id", params.sessionID))
case sessionID := <-s.unregister:
s.mu.Lock()
if conn, ok := s.clients[sessionID]; ok {
conn.Close()
delete(s.clients, sessionID)
}
s.mu.Unlock()
logger.Log.Info("WS Client disconnected", zap.String("session_id", sessionID))
}
}
}
// HandleConnections обрабатывает входящие WS запросы
func (s *Server) HandleConnections(c *gin.Context) {
sessionID := c.Query("session_id")
if sessionID == "" {
c.Status(http.StatusBadRequest)
return
}
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Log.Error("WS Upgrade error", zap.Error(err))
return
}
s.register <- &clientParams{sessionID: sessionID, conn: conn}
// Читаем сообщения, чтобы держать соединение открытым и ловить Disconnect
go func() {
defer func() {
s.unregister <- sessionID
}()
for {
if _, _, err := conn.NextReader(); err != nil {
break
}
}
}()
}
// SendAuthSuccess отправляет токен и закрывает соединение (так как задача выполнена)
func (s *Server) SendAuthSuccess(sessionID string, token string, user account.User) {
s.mu.RLock()
conn, ok := s.clients[sessionID]
s.mu.RUnlock()
if !ok {
logger.Log.Warn("WS Client not found for auth", zap.String("session_id", sessionID))
return
}
resp := map[string]interface{}{
"event": "auth_success",
"data": map[string]interface{}{
"token": token,
"user": map[string]interface{}{
"id": user.ID,
"telegram_id": user.TelegramID,
"username": user.Username,
"first_name": user.FirstName,
"last_name": user.LastName,
"photo_url": user.PhotoURL,
"is_system_admin": user.IsSystemAdmin,
},
},
}
if err := conn.WriteJSON(resp); err != nil {
logger.Log.Error("WS Write error", zap.Error(err))
} else {
logger.Log.Info("WS Auth sent successfully", zap.String("session_id", sessionID))
}
// Даем время на доставку и закрываем
time.Sleep(500 * time.Millisecond)
s.unregister <- sessionID
}