Files
marketplaces/docs/BACKEND_AUTH_INTEGRATION.md
sdarbinyan 6de461473e added docs
2026-03-25 15:42:27 +04:00

27 KiB
Raw Permalink Blame History

Авторизация через Telegram — Backend & Bot

Всё что нужно Go-разработчику для реализации авторизации. Фронтенд полностью готов и ждёт эти эндпоинты.


Статус

Компонент Готов?
Frontend (Angular) — диалог, QR, поллинг, корзина Готов
Telegram бот (обработка /start) Нужно
Backend — 6 HTTP-эндпоинтов Нужно
Хранилище сессий + QR-токенов Нужно
CORS для cookie-based запросов Нужно

Архитектура

Два сценария авторизации:

Сценарий 1: Прямой вход (кнопка "Войти через Telegram")

Пользователь нажимает кнопку → открывается Telegram → бот выдаёт кнопку "Войти на сайт" → callback ставит cookie → фронтенд поллит /auth/session.

Сценарий 2: QR-логин с десктопа (основной)

 ДЕСКТОП БРАУЗЕР                    СЕРВЕР (Go)                 TELEGRAM
      │                                  │                          │
      │  1. POST /auth/qr/create         │                          │
      │ ─────────────────────────────>   │                          │
      │  { token: "abc", url: "..." }    │                          │
      │ <─────────────────────────────   │                          │
      │                                  │                          │
      │  2. Показать QR:                 │                          │
      │     t.me/Bot?start=login_abc     │                          │
      │                                  │                          │
      │                            ПОЛЬЗОВАТЕЛЬ СКАНИРУЕТ ТЕЛЕФОНОМ │
      │                                  │                          │
      │                                  │  3. /start login_abc     │
      │                                  │ <────────────────────────│
      │                                  │                          │
      │                                  │  Бот → POST /auth/qr/confirm
      │                                  │  Бот → "✅ Вы вошли!"   │
      │                                  │ ────────────────────────>│
      │                                  │                          │
      │  4. GET /auth/qr/poll?token=abc  │                          │
      │     (каждые 3 сек)               │                          │
      │ ─────────────────────────────>   │                          │
      │  { status: "confirmed",          │                          │
      │    session: {...} }              │                          │
      │  + Set-Cookie: dx_session=...    │                          │
      │ <─────────────────────────────   │                          │
      │                                  │                          │
      │  5. POST /websession/{sessionId} │                          │
      │     [{ itemID, quantity, ... }]  │  ← корзина               │
      │ ─────────────────────────────>   │                          │
      │                                  │                          │
      │  6. Готово! Авторизован + корзина│                          │

Бренды и боты

Бренд Username бота Домен фронтенда API сервер Cookie Domain
Dexar DexarSupport_bot dexarmarket.ru api.dexarmarket.ru:445 .dexarmarket.ru
Novo novomarket_bot novo.market api.novo.market:444 .novo.market

Бот создаётся через https://t.me/BotFather/newbot. Сохранить BOT_TOKEN.


Хранилище

Структура: Сессия

type Session struct {
    SessionID      string    `json:"sessionId"`
    TelegramUserID int64     `json:"telegramUserId"`
    Username       *string   `json:"username"`       // может быть null
    DisplayName    string    `json:"displayName"`
    Active         bool      `json:"active"`
    ExpiresAt      time.Time `json:"expiresAt"`
}

TTL: 24 часа.

Структура: QR-токен (одноразовый)

type AuthToken struct {
    Token     string    `json:"token"`
    Status    string    `json:"status"`    // "pending" | "confirmed" | "expired"
    SessionID string    `json:"sessionId"` // заполняется после подтверждения ботом
    CreatedAt time.Time `json:"createdAt"`
    ExpiresAt time.Time `json:"expiresAt"`
}

TTL: 5 минут.

Варианты хранения

Redis (рекомендуется):

// Сессия
redisClient.Set(ctx, "session:"+s.SessionID, json, 24*time.Hour)

// QR-токен
redisClient.Set(ctx, "auth_token:"+t.Token, json, 5*time.Minute)

sync.Map (для MVP):

var sessions sync.Map
var authTokens sync.Map

// Очистка устаревших токенов — запустить горутину при старте
func cleanupExpiredTokens() {
    ticker := time.NewTicker(1 * time.Minute)
    for range ticker.C {
        authTokens.Range(func(key, value any) bool {
            t := value.(AuthToken)
            if time.Now().After(t.ExpiresAt) {
                authTokens.Delete(key)
            }
            return true
        })
    }
}

HTTP-эндпоинты

1. POST /auth/qr/create

Фронтенд вызывает при открытии диалога логина. Создаёт одноразовый QR-токен.

func handleQrCreate(w http.ResponseWriter, r *http.Request) {
    // 1. Сгенерировать криптографически безопасный токен
    tokenBytes := make([]byte, 32)
    if _, err := rand.Read(tokenBytes); err != nil {
        http.Error(w, "internal error", 500)
        return
    }
    token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)

    // 2. Определить бота по origin
    botUsername := getBotForOrigin(r.Header.Get("Origin"))

    // 3. Сохранить токен
    authToken := AuthToken{
        Token:     token,
        Status:    "pending",
        CreatedAt: time.Now(),
        ExpiresAt: time.Now().Add(5 * time.Minute),
    }
    saveAuthToken(authToken)

    // 4. Ответить
    qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "token": token,
        "url":   qrURL,
    })
}

func getBotForOrigin(origin string) string {
    if strings.Contains(origin, "novo.market") {
        return "novomarket_bot"
    }
    return "DexarSupport_bot"
}

Ответ:

{ "token": "dG9rZW4tYWJj....", "url": "https://t.me/DexarSupport_bot?start=login_dG9rZW4tYWJj...." }

Telegram ограничивает start до 64 символов. login_ (6) + base64url из 32 байт (43) = 49


2. GET /auth/qr/poll?token={token}

Фронтенд вызывает каждые 3 секунды. Когда бот подтвердил — возвращает сессию и ставит cookie.

func handleQrPoll(w http.ResponseWriter, r *http.Request) {
    tokenStr := r.URL.Query().Get("token")
    if tokenStr == "" {
        http.Error(w, "missing token", 400)
        return
    }

    authToken, ok := getAuthToken(tokenStr)
    if !ok {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "expired"})
        return
    }

    switch authToken.Status {
    case "pending":
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "pending"})

    case "confirmed":
        session, err := getSession(authToken.SessionID)
        if err != nil {
            http.Error(w, "session not found", 500)
            return
        }

        // Cookie в ДЕСКТОПНЫЙ браузер
        domain := getDomainForOrigin(r.Header.Get("Origin"))
        http.SetCookie(w, &http.Cookie{
            Name:     "dx_session",
            Value:    session.SessionID,
            Path:     "/",
            HttpOnly: true,
            Secure:   true,
            SameSite: http.SameSiteNoneMode,
            MaxAge:   86400,
            Domain:   domain,
        })

        // Удалить использованный токен
        deleteAuthToken(tokenStr)

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "status":  "confirmed",
            "session": session,
        })
    }
}

func getDomainForOrigin(origin string) string {
    if strings.Contains(origin, "novo.market") {
        return ".novo.market"
    }
    return ".dexarmarket.ru"
}

Ответы:

Статус JSON
Ждём { "status": "pending" }
Подтверждено { "status": "confirmed", "session": { sessionId, telegramUserId, username, displayName, active, expiresAt } } + Set-Cookie
Истекло { "status": "expired" }

3. POST /auth/qr/confirm (внутренний, для бота)

Бот вызывает когда пользователь отсканировал QR. Привязывает сессию к токену.

func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
    // Проверить секрет бота
    if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
        http.Error(w, "forbidden", 403)
        return
    }

    var req struct {
        Token string `json:"token"`
        User  struct {
            ID        int64  `json:"id"`
            FirstName string `json:"first_name"`
            LastName  string `json:"last_name"`
            Username  string `json:"username"`
        } `json:"telegram_user"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", 400)
        return
    }

    authToken, ok := getAuthToken(req.Token)
    if !ok || authToken.Status != "pending" {
        http.Error(w, "token not found or already used", 404)
        return
    }

    // Создать сессию
    displayName := req.User.FirstName
    if req.User.LastName != "" {
        displayName += " " + req.User.LastName
    }
    var username *string
    if req.User.Username != "" {
        username = &req.User.Username
    }

    session := Session{
        SessionID:      uuid.New().String(),
        TelegramUserID: req.User.ID,
        Username:       username,
        DisplayName:    displayName,
        Active:         true,
        ExpiresAt:      time.Now().Add(24 * time.Hour),
    }
    saveSession(session)

    // Привязать сессию к токену
    authToken.Status = "confirmed"
    authToken.SessionID = session.SessionID
    saveAuthToken(*authToken)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

Запрос от бота:

{
  "token": "dG9rZW4tYWJj...",
  "telegram_user": {
    "id": 123456789,
    "first_name": "Иван",
    "last_name": "Петров",
    "username": "ivan_petrov"
  }
}

4. GET /auth/session

Фронтенд вызывает для проверки текущей сессии. Читает cookie.

func handleGetSession(w http.ResponseWriter, r *http.Request) {
    cookie, err := r.Cookie("dx_session")
    if err != nil {
        http.Error(w, "unauthorized", 401)
        return
    }

    session, err := getSession(cookie.Value)
    if err != nil {
        http.Error(w, "unauthorized", 401)
        return
    }

    if time.Now().After(session.ExpiresAt) {
        session.Active = false
        saveSession(session)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(session)
}

Формат ответа (200) — фронтенд ожидает точно эти поля:

{
  "sessionId":      "550e8400-e29b-41d4-a716-446655440000",
  "telegramUserId": 123456789,
  "username":       "ivan_petrov",
  "displayName":    "Иван Петров",
  "active":         true,
  "expiresAt":      "2026-03-25T14:30:00Z"
}
Поле Тип Обязательно Примечание
sessionId string (UUID) да Используется для /websession/{sessionId}
telegramUserId number да Telegram user ID
username string / null нет Telegram @username
displayName string да "Имя Фамилия" — показывается в UI
active boolean да false = истекла
expiresAt string (ISO 8601) да Фронтенд перепроверяет за 60 сек до

Ошибка: любой HTTP не-200 → фронтенд считает "не авторизован".


5. GET /auth/telegram/callback

Для прямого входа (по кнопке в Telegram, не через QR). Открывается в браузере.

func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
    token := r.URL.Query().Get("token")
    if token == "" {
        http.Error(w, "missing token", 400)
        return
    }

    session, err := getSession(token)
    if err != nil || !session.Active {
        http.Error(w, "invalid or expired token", 401)
        return
    }

    domain := getDomainForOrigin(r.Header.Get("Origin"))
    if domain == "" {
        domain = ".dexarmarket.ru" // fallback для прямого перехода
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "dx_session",
        Value:    session.SessionID,
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteNoneMode,
        MaxAge:   86400,
        Domain:   domain,
    })

    // Редирект на сайт
    http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
}

6. POST /auth/logout

func handleLogout(w http.ResponseWriter, r *http.Request) {
    cookie, err := r.Cookie("dx_session")
    if err == nil {
        deleteSession(cookie.Value)
    }

    http.SetCookie(w, &http.Cookie{
        Name:     "dx_session",
        Value:    "",
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteNoneMode,
        MaxAge:   -1,
        Domain:   ".dexarmarket.ru",
    })

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"message":"ok"}`))
}

Параметр Значение Почему
Name dx_session
SameSite None Фронтенд на dexarmarket.ru, API на api.dexarmarket.ru:445 — разные origins
Secure true Обязательно при SameSite=None
Domain .dexarmarket.ru Доступна и на dexarmarket.ru и на api.dexarmarket.ru
HttpOnly true Недоступна из JS — защита от XSS
MaxAge 86400 24 часа

CORS

Фронтенд шлёт withCredentials: true. Бэкенд обязан вернуть правильные заголовки.

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")

        allowed := map[string]bool{
            "https://dexarmarket.ru":     true,
            "https://www.dexarmarket.ru": true,
            "https://novo.market":        true,
            "https://www.novo.market":    true,
            "http://localhost:4200":       true,
        }

        if allowed[origin] {
            w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*"
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
        }

        if r.Method == "OPTIONS" {
            w.WriteHeader(200)
            return
        }

        next.ServeHTTP(w, r)
    })
}

Критично: Access-Control-Allow-Origin не может быть "*" при withCredentials. Вернуть конкретный origin.


Роутинг

mux := http.NewServeMux()

// Существующие
mux.HandleFunc("GET /items/{id}", handleGetItem)
mux.HandleFunc("GET /category", handleGetCategories)
mux.HandleFunc("POST /websession/{id}", handleWebSession)
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)

// Auth — прямой вход
mux.HandleFunc("GET /auth/session", handleGetSession)
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
mux.HandleFunc("POST /auth/logout", handleLogout)

// Auth — QR-логин
mux.HandleFunc("POST /auth/qr/create", handleQrCreate)
mux.HandleFunc("GET /auth/qr/poll", handleQrPoll)
mux.HandleFunc("POST /auth/qr/confirm", handleQrConfirm)

handler := corsMiddleware(mux)
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)

Telegram-бот

Обработчик /start

const (
    confirmURL        = "http://localhost:8080/auth/qr/confirm"
    botInternalSecret = os.Getenv("BOT_INTERNAL_SECRET")
)

func handleStart(update tgbotapi.Update) {
    text := update.Message.Text
    user := update.Message.From

    switch {
    case strings.HasPrefix(text, "/start login_"):
        handleQrLogin(update, user, strings.TrimPrefix(text, "/start login_"))

    case strings.HasPrefix(text, "/start auth"):
        handleDirectAuth(update, user)

    default:
        sendWelcome(update)
    }
}

QR-логин (основной)

func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
    reqBody := map[string]interface{}{
        "token": token,
        "telegram_user": map[string]interface{}{
            "id":         user.ID,
            "first_name": user.FirstName,
            "last_name":  user.LastName,
            "username":   user.UserName,
        },
    }
    bodyBytes, _ := json.Marshal(reqBody)

    req, _ := http.NewRequest("POST", confirmURL, bytes.NewReader(bodyBytes))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Bot-Secret", botInternalSecret)

    resp, err := http.DefaultClient.Do(req)
    if err != nil || resp.StatusCode != 200 {
        msg := tgbotapi.NewMessage(update.Message.Chat.ID,
            "❌ Не удалось войти. QR-код мог устареть. Попробуйте обновить страницу и отсканировать новый QR.")
        bot.Send(msg)
        return
    }
    defer resp.Body.Close()

    displayName := buildDisplayName(user)
    msg := tgbotapi.NewMessage(update.Message.Chat.ID,
        fmt.Sprintf("✅ Вы вошли на сайт как %s!\n\nМожете вернуться в браузер — страница обновится автоматически.", displayName))
    bot.Send(msg)
}

func buildDisplayName(user *tgbotapi.User) string {
    name := user.FirstName
    if user.LastName != "" {
        name += " " + user.LastName
    }
    return name
}

Прямой вход (кнопка, для обратной совместимости)

func handleDirectAuth(update tgbotapi.Update, user *tgbotapi.User) {
    session := Session{
        SessionID:      uuid.New().String(),
        TelegramUserID: user.ID,
        Username:       stringPtr(user.UserName),
        DisplayName:    buildDisplayName(user),
        Active:         true,
        ExpiresAt:      time.Now().Add(24 * time.Hour),
    }
    saveSession(session)

    callbackURL := "https://api.dexarmarket.ru:445/auth/telegram/callback"
    loginURL := callbackURL + "?token=" + session.SessionID

    msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
    msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
        tgbotapi.NewInlineKeyboardRow(
            tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
        ),
    )
    bot.Send(msg)
}

Запуск бота (long polling)

func main() {
    bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))

    u := tgbotapi.NewUpdate(0)
    u.Timeout = 60
    updates := bot.GetUpdatesChan(u)

    for update := range updates {
        if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
            handleStart(update)
        }
    }
}

Синхронизация корзины

Сразу после QR-логина фронтенд автоматически отправляет корзину:

POST /websession/{sessionId}

Тело — массив:

[
  {
    "itemID": 123,
    "quantity": 2,
    "colour": "#ff0000",
    "size": "XL",
    "price": 1500
  }
]
Поле Тип Примечание
itemID number ID товара
quantity number Количество
colour string CSS hex (#ff0000). Бэкенд отдаёт 0xff0000, фронтенд конвертирует
size string "default" если размер один
price number Финальная цена с учётом скидки

Этот эндпоинт (POST /websession/{id}) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина.


Безопасность

Криптографический токен

tokenBytes := make([]byte, 32)  // 256 бит
crypto/rand.Read(tokenBytes)
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)

НЕ использовать: math/rand, UUID, timestamp.

Токен одноразовый

  • После confirmed → удалить при первом успешном poll
  • После 5 минут → автоудаление (TTL)
  • Повторный poll"expired"

Защита /auth/qr/confirm

if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
    http.Error(w, "forbidden", 403)
    return
}

Дополнительно: можно ограничить по IP (127.0.0.1) если бот на том же сервере.

Rate limiting для /auth/qr/create

Не более 5 токенов в минуту с одного IP:

var ipCounts sync.Map

func rateLimitQrCreate(ip string) bool {
    key := ip + ":" + time.Now().Format("2006-01-02T15:04")
    val, _ := ipCounts.LoadOrStore(key, new(int32))
    count := atomic.AddInt32(val.(*int32), 1)
    return count <= 5
}

Переменные окружения

BOT_TOKEN=123456:ABC-DEF...
BOT_INTERNAL_SECRET=случайная-строка-минимум-32-символа
FRONTEND_URL=https://dexarmarket.ru
SESSION_TTL=24h
REDIS_URL=localhost:6379

BOT_INTERNAL_SECRET должен совпадать в env сервера и env бота.


Тестирование

curl-тесты

1. Создание токена:

curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \
  -H "Origin: https://dexarmarket.ru"
# → { "token": "dG9r...", "url": "https://t.me/DexarSupport_bot?start=login_dG9r..." }

2. Поллинг (до подтверждения):

curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "pending" }

3. Подтверждение (имитация бота):

curl -X POST https://api.dexarmarket.ru:445/auth/qr/confirm \
  -H "Content-Type: application/json" \
  -H "X-Bot-Secret: ваш-секрет" \
  -d '{"token":"dG9r...","telegram_user":{"id":123,"first_name":"Тест","last_name":"","username":"testuser"}}'
# → { "status": "ok" }

4. Поллинг (после подтверждения):

curl -v "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "confirmed", "session": {...} } + Set-Cookie: dx_session=...

5. E2E:

  1. Открыть маркетплейс → добавить товар в корзину
  2. Нажать "Оформить заказ" → появляется диалог с QR
  3. Отсканировать QR телефоном → Telegram → бот: " Вы вошли!"
  4. Через 3 сек диалог закрывается → авторизован
  5. Корзина синхронизирована (POST /websession/{sessionId})

Отладка

Проблема Где смотреть
QR не показывается POST /auth/qr/create — ошибка? CORS?
QR отсканирован, ничего не происходит Бот получил /start login_...? Бот вызвал confirm?
Бот пишет " QR устарел" Токен expired? 5 минут прошло?
Поллинг "pending" бесконечно Бот не вызвал confirm. Логи бота
Поллинг "confirmed" но cookie нет SameSite, Secure, Domain, CORS

Чеклист

Бэкенд (Go)

  • Структура Session + AuthToken, функции save/get/delete
  • POST /auth/qr/create — генерация токена
  • GET /auth/qr/poll?token=... — статус + cookie при confirmed
  • POST /auth/qr/confirm — приём от бота с X-Bot-Secret
  • GET /auth/session — чтение cookie, JSON сессии
  • GET /auth/telegram/callback?token=... — cookie + редирект
  • POST /auth/logout — удаление сессии и cookie
  • TTL 5 мин для токенов, 24ч для сессий
  • Rate limiting /auth/qr/create (5/мин/IP)
  • Очистка устаревших токенов
  • CORS middleware
  • BOT_INTERNAL_SECRET в env

Telegram бот

  • Обработка /start login_{token}POST /auth/qr/confirm
  • Обработка /start auth → создание сессии + кнопка "Войти"
  • Сообщения: " Вы вошли" / " QR устарел"
  • BOT_INTERNAL_SECRET в env (совпадает с сервером)
  • BOT_TOKEN в env