Files
marketplaces/docs/QR_LOGIN_IMPLEMENTATION_RU.md
2026-03-25 15:32:50 +04:00

38 KiB
Raw Blame History

Авторизация через Telegram QR-код — Подробный план реализации

Проблема: текущий QR-код содержит ссылку https://t.me/Bot?start=auth_.... Когда пользователь сканирует его телефоном, callback (/auth/telegram/callback) открывается в браузере телефона, а не в десктопном браузере где открыт маркетплейс. Cookie попадает на телефон → десктоп так и не авторизуется.

Решение: QR-логин должен работать по polling-схеме — фронтенд генерирует одноразовый токен, показывает его в QR, и поллит бэкенд. Бот сообщает бэкенду "этот токен подтверждён пользователем X" напрямую, без участия браузера телефона.


Оглавление

  1. Как это будет работать (схема)
  2. Что нужно изменить на бэкенде (Go)
  3. Что нужно изменить в Telegram боте
  4. Что нужно изменить на фронтенде (Angular)
  5. Хранилище auth_tokens
  6. Безопасность
  7. Тестирование
  8. Чеклист

1. Как это будет работать

Общая схема

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

Ключевое отличие от старой схемы

Старая схема (не работает) Новая схема (polling)
QR содержит t.me/Bot?start=auth_CALLBACK_URL t.me/Bot?start=login_TOKEN
Кто получает cookie Браузер телефона (бесполезно) Браузер десктопа (через poll)
Участие телефонного браузера Да — callback открывается в нём Нет — всё происходит внутри Telegram
Как десктоп узнаёт об авторизации Никак Поллинг /auth/qr/poll

2. Бэкенд (Go) — 4 новых эндпоинта

2.1. Хранилище auth-токенов

Нужна вторая структура (помимо сессий) — одноразовые токены для 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"`
}

Время жизни: 5 минут (этого достаточно чтобы достать телефон и отсканировать).

Где хранить:

  • Redis (prod): SET auth_token:{token} {json} EX 300
  • sync.Map (dev/MVP): обычная карта с очисткой по таймеру
// Redis вариант:
func saveAuthToken(token AuthToken) error {
    data, _ := json.Marshal(token)
    return redisClient.Set(ctx, "auth_token:"+token.Token, data, 5*time.Minute).Err()
}

func getAuthToken(token string) (*AuthToken, error) {
    data, err := redisClient.Get(ctx, "auth_token:"+token).Bytes()
    if err != nil {
        return nil, err
    }
    var t AuthToken
    json.Unmarshal(data, &t)
    return &t, nil
}
// sync.Map вариант (для MVP):
var authTokens sync.Map

func saveAuthToken(token AuthToken) {
    authTokens.Store(token.Token, token)
}

func getAuthToken(tokenStr string) (*AuthToken, bool) {
    val, ok := authTokens.Load(tokenStr)
    if !ok {
        return nil, false
    }
    t := val.(AuthToken)
    if time.Now().After(t.ExpiresAt) {
        authTokens.Delete(tokenStr)
        return nil, false
    }
    return &t, true
}

// Запустить при старте сервера — очистка устаревших токенов
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
        })
    }
}

2.2. Эндпоинт POST /auth/qr/create

Кто вызывает: фронтенд, когда пользователь открывает диалог логина.

Что делает: создаёт одноразовый токен и возвращает его + URL для QR.

// POST /auth/qr/create
func handleQrCreate(w http.ResponseWriter, r *http.Request) {
    // 1. Сгенерировать криптографически безопасный токен
    tokenBytes := make([]byte, 32)
    _, err := rand.Read(tokenBytes)
    if 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. Сформировать URL для QR (ссылка на бота)
    qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)

    // 5. Ответить
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "token": token,
        "url":   qrURL,
    })
}

// Определить бота по origin запроса
func getBotForOrigin(origin string) string {
    switch {
    case strings.Contains(origin, "novo.market"):
        return "novomarket_bot"
    default:
        return "DexarSupport_bot"
    }
}

Ответ (200):

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

Важно: параметр start в Telegram ограничен 64 символами (base64, только [A-Za-z0-9_-]). login_ = 6 символов, значит сам токен не больше 58 символов. 32 байта в base64url = 43 символа → итого login_ + 43 = 49 символов влезает.


2.3. Эндпоинт GET /auth/qr/poll

Кто вызывает: фронтенд, каждые 2-3 секунды после показа QR.

Что делает: проверяет статус токена. Если бот подтвердил — возвращает сессию и ставит cookie.

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

    // 1. Найти токен
    authToken, ok := getAuthToken(tokenStr)
    if !ok {
        // Токен не найден или истёк
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{
            "status": "expired",
        })
        return
    }

    // 2. Проверить статус
    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 {
    switch {
    case strings.Contains(origin, "novo.market"):
        return ".novo.market"
    default:
        return ".dexarmarket.ru"
    }
}

Ответы:

Ожидание:

{ "status": "pending" }

Подтверждено:

{
  "status": "confirmed",
  "session": {
    "sessionId": "550e8400-...",
    "telegramUserId": 123456789,
    "username": "ivan_petrov",
    "displayName": "Иван Петров",
    "active": true,
    "expiresAt": "2026-03-26T14:30:00Z"
  }
}

Истекло:

{ "status": "expired" }

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

Кто вызывает: Telegram бот, когда пользователь нажал /start login_{token}.

Что делает: привязывает сессию к токену.

// POST /auth/qr/confirm
// Тело: { "token": "abc...", "telegram_user": { "id": 123, "first_name": "...", ... } }
func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
    // 1. Проверить что запрос от бота (по секретному ключу)
    botSecret := r.Header.Get("X-Bot-Secret")
    if botSecret != os.Getenv("BOT_INTERNAL_SECRET") {
        http.Error(w, "forbidden", 403)
        return
    }

    // 2. Прочитать тело
    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
    }

    // 3. Найти токен
    authToken, ok := getAuthToken(req.Token)
    if !ok || authToken.Status != "pending" {
        http.Error(w, "token not found or already used", 404)
        return
    }

    // 4. Создать сессию
    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)

    // 5. Обновить токен — привязать сессию
    authToken.Status = "confirmed"
    authToken.SessionID = session.SessionID
    saveAuthToken(*authToken)

    // 6. Ответить боту
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "ok",
    })
}

Безопасность: эндпоинт защищён заголовком X-Bot-Secretего знает только бот. Это предотвращает подделку подтверждений.


2.5. Добавить новые роуты к серверу

mux := http.NewServeMux()

// Существующие эндпоинты
mux.HandleFunc("GET /items/{id}", handleGetItem)
mux.HandleFunc("GET /category", handleGetCategories)
// ... другие ...

// 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)

3. Telegram бот — обработка /start login_{token}

Что меняется

Раньше бот обрабатывал /start auth_CALLBACK_URL — отправлял кнопку с URL.

Теперь бот обрабатывает /start login_{token}сам вызывает бэкенд и подтверждает авторизацию. Никакой кнопки "Войти на сайт" не нужно!

Новый handleStart

const (
    // URL бэкенда для подтверждения токена (внутренний, не публичный)
    confirmURL      = "http://localhost:8080/auth/qr/confirm"  // или внутренний адрес
    botInternalSecret = "ваш-секретный-ключ-для-бота"         // из env
)

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"):
        // Старый flow через кнопку (оставить для обратной совместимости)
        handleDirectAuth(update, user)

    default:
        // Обычный /start — приветствие
        sendWelcome(update)
    }
}

func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
    // 1. Подготовить данные пользователя
    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)

    // 2. Вызвать бэкенд для подтверждения
    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()

    // 3. Успех — сообщить пользователю
    displayName := user.FirstName
    if user.LastName != "" {
        displayName += " " + user.LastName
    }

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

Что видит пользователь в Telegram

Пользователь:  /start login_dG9rZW4tYWJj...   (автоматически при открытии ссылки)

Бот:           ✅ Вы вошли на сайт как Иван Петров!
               Можете вернуться в браузер — страница обновится автоматически.

Никаких дополнительных кнопок нажимать не нужно! Один скан QR → автоматический вход.


4. Фронтенд (Angular) — изменения

4.1. Изменить AuthService

Нужно добавить два метода: создание QR-токена и поллинг.

Файл: src/app/services/auth.service.ts

Добавить:

// Создать QR-токен для авторизации
createQrToken(): Observable<{ token: string; url: string }> {
  return this.http.post<{ token: string; url: string }>(
    `${this.apiUrl}/auth/qr/create`,
    {},
    { withCredentials: true }
  );
}

// Проверить статус QR-токена
pollQrToken(token: string): Observable<QrPollResponse> {
  return this.http.get<QrPollResponse>(
    `${this.apiUrl}/auth/qr/poll`,
    {
      params: { token },
      withCredentials: true,
    }
  );
}

Добавить интерфейс в auth.model.ts:

export interface QrPollResponse {
  status: 'pending' | 'confirmed' | 'expired';
  session?: AuthSession;
}

4.2. Изменить TelegramLoginComponent

Файл: src/app/components/telegram-login/telegram-login.component.ts

Вместо статичной ссылки — при открытии диалога запрашивать одноразовый токен:

import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { TranslatePipe } from '../../i18n/translate.pipe';

@Component({
  selector: 'app-telegram-login',
  imports: [TranslatePipe],
  templateUrl: './telegram-login.component.html',
  styleUrls: ['./telegram-login.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelegramLoginComponent implements OnInit, OnDestroy {
  private authService = inject(AuthService);

  showDialog = this.authService.showLoginDialog;
  status = this.authService.status;

  loginUrl = signal('');
  qrToken = signal('');
  qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');

  private pollTimer?: ReturnType<typeof setInterval>;

  ngOnInit(): void {
    // Будет вызвано при создании компонента
  }

  ngOnDestroy(): void {
    this.stopPolling();
  }

  /** Вызывается когда диалог открывается — запросить свежий QR-токен */
  initQrLogin(): void {
    this.qrStatus.set('loading');
    this.authService.createQrToken().subscribe({
      next: (res) => {
        this.loginUrl.set(res.url);
        this.qrToken.set(res.token);
        this.qrStatus.set('ready');
        this.startPolling(res.token);
      },
      error: () => {
        // Fallback — старая ссылка (прямой вход через кнопку всё ещё работает)
        this.loginUrl.set(this.authService.getTelegramLoginUrl());
        this.qrStatus.set('error');
      }
    });
  }

  close(): void {
    this.authService.hideLogin();
    this.stopPolling();
  }

  openTelegramLogin(): void {
    window.open(this.loginUrl(), '_blank');
    // Если QR-поллинг уже идёт — не запускать второй
    if (!this.pollTimer) {
      this.startPolling(this.qrToken());
    }
  }

  refreshQr(): void {
    this.stopPolling();
    this.initQrLogin();
  }

  private startPolling(token: string): void {
    this.stopPolling();
    if (!token) return;

    let checks = 0;
    this.pollTimer = setInterval(() => {
      checks++;

      // Таймаут через 5 минут (100 * 3 сек = 300 сек)
      if (checks > 100) {
        this.stopPolling();
        this.qrStatus.set('expired');
        return;
      }

      this.authService.pollQrToken(token).subscribe({
        next: (res) => {
          switch (res.status) {
            case 'confirmed':
              // Успех! Cookie уже установлена ответом poll
              this.stopPolling();
              this.authService.onTelegramLoginComplete();
              break;
            case 'expired':
              this.stopPolling();
              this.qrStatus.set('expired');
              break;
            // 'pending' — продолжаем поллинг
          }
        },
        error: () => {
          // Сетевая ошибка — продолжаем поллинг
        }
      });
    }, 3000);
  }

  private stopPolling(): void {
    if (this.pollTimer) {
      clearInterval(this.pollTimer);
      this.pollTimer = undefined;
    }
  }
}

4.3. Изменить шаблон (HTML)

Файл: src/app/components/telegram-login/telegram-login.component.html

@if (showDialog()) {
  <div class="login-overlay" (click)="close()">
    <div class="login-dialog" (click)="$event.stopPropagation()">
      <button class="close-btn" (click)="close()">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M18 6L6 18M6 6l12 12"/>
        </svg>
      </button>

      <div class="login-icon">
        <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
          <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
        </svg>
      </div>

      <h2>{{ 'auth.loginRequired' | translate }}</h2>
      <p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>

      @if (status() === 'checking') {
        <div class="login-status checking">
          <div class="spinner"></div>
          <span>{{ 'auth.checking' | translate }}</span>
        </div>
      } @else {
        <!-- Кнопка "Войти через Telegram" (прямой переход) -->
        <button class="telegram-btn" (click)="openTelegramLogin()">
          <svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
            <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
          </svg>
          {{ 'auth.loginWithTelegram' | translate }}
        </button>

        <!-- QR-секция -->
        <div class="qr-section">
          <p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>

          @switch (qrStatus()) {
            @case ('loading') {
              <div class="qr-container qr-loading">
                <div class="spinner"></div>
              </div>
            }
            @case ('ready') {
              <div class="qr-container">
                <img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
                     alt="QR Code"
                     width="180"
                     height="180"
                     loading="eager" />
              </div>
            }
            @case ('expired') {
              <div class="qr-container qr-expired" (click)="refreshQr()">
                <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                  <path d="M1 4v6h6M23 20v-6h-6"/>
                  <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
                </svg>
                <span>{{ 'auth.qrExpired' | translate }}</span>
              </div>
            }
            @case ('error') {
              <!-- Fallback: показать QR со старой ссылкой (прямой вход) -->
              <div class="qr-container">
                <img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
                     alt="QR Code"
                     width="180"
                     height="180"
                     loading="lazy" />
              </div>
            }
          }
        </div>

        <p class="login-note">{{ 'auth.loginNote' | translate }}</p>
      }
    </div>
  </div>
}

Добавить в компонент computed для encodedQrUrl:

encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));

4.4. Инициализация QR при открытии диалога

В TelegramLoginComponent нужно реагировать на showDialog() — когда он становится true, запросить свежий QR:

// В ngOnInit:
ngOnInit(): void {
  // Подписаться на изменение showDialog
  // Используем effect для отслеживания сигнала
  effect(() => {
    if (this.showDialog()) {
      this.initQrLogin();
    } else {
      this.stopPolling();
    }
  });
}

Не забудьте импортировать effect из @angular/core и computed.


4.5. Обновить стили

Добавить в telegram-login.component.scss:

.qr-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 204px;   // 180 + 24 padding
  height: 204px;

  .spinner {
    width: 32px;
    height: 32px;
    border: 3px solid #e0e0e0;
    border-top-color: var(--accent-color, #497671);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
}

.qr-expired {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 204px;
  height: 204px;
  cursor: pointer;
  color: var(--text-secondary, #999);
  transition: color 0.2s ease;

  &:hover {
    color: var(--accent-color, #497671);
  }

  span {
    font-size: 13px;
  }
}

4.6. Добавить новые ключи переводов

Файл: src/app/i18n/ru.ts (и аналогично en.ts, hy.ts)

'auth.qrExpired': 'QR-код устарел. Нажмите чтобы обновить',

5. Хранилище auth_tokens — итоговая структура

┌─────────────────────────────────────────────────────┐
│  Хранилище (Redis / sync.Map / PostgreSQL)          │
│                                                     │
│  auth_token:{token}                                 │
│    ├── token:     "dG9rZW4tYWJj..."                 │
│    ├── status:    "pending" | "confirmed" | "expired"│
│    ├── sessionId: ""  →  "550e8400-..." (после confirm)│
│    ├── createdAt: "2026-03-25T12:00:00Z"            │
│    └── expiresAt: "2026-03-25T12:05:00Z"  (5 мин)   │
│                                                     │
│  session:{sessionId}                                │
│    ├── sessionId:      "550e8400-..."               │
│    ├── telegramUserId: 123456789                    │
│    ├── username:       "ivan_petrov"                │
│    ├── displayName:    "Иван Петров"                │
│    ├── active:         true                         │
│    └── expiresAt:      "2026-03-26T12:00:00Z" (24ч) │
└─────────────────────────────────────────────────────┘

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

6.1. Токен должен быть криптографически случайным

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

НЕ использовать: math/rand, UUID, timestamp, или предсказуемые значения.

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

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

6.3. Защита POST /auth/qr/confirm

Этот эндпоинт вызывается только ботом. Защита:

// В .env:
BOT_INTERNAL_SECRET=случайная-строка-32-символа

// В обработчике:
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
    http.Error(w, "forbidden", 403)
    return
}

Дополнительно можно:

  • Ограничить доступ по IP (если бот на том же сервере → только 127.0.0.1/localhost)
  • Использовать внутренний порт недоступный снаружи

6.4. Rate limiting

Ограничить POST /auth/qr/create:

  • Не более 5 токенов в минуту с одного IP
  • Это предотвращает спам-создание токенов
// Простейший лимитер на sync.Map
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
}

6.5. CORS

Новые эндпоинты /auth/qr/* должны быть включены в тот же CORS middleware что и /auth/session. Поскольку они уже под /auth/ — middleware покроет их автоматически.


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

Шаг за шагом

Подготовка:

  1. Добавить BOT_INTERNAL_SECRET в env бота и сервера (одинаковое значение)
  2. Добавить обработку /start login_ в бота
  3. Добавить 3 эндпоинта на сервер
  4. Обновить фронтенд

Тест 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 секунды диалог логина закрывается → вы авторизованы

Логи для отладки

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

8. Чеклист

Бэкенд (Go)

  • Структура AuthToken и функции save/get/deleteAuthToken
  • POST /auth/qr/create — генерация токена, возврат URL
  • GET /auth/qr/poll?token=... — проверка статуса, установка cookie при confirmed
  • POST /auth/qr/confirm — приём подтверждения от бота (с проверкой X-Bot-Secret)
  • TTL для токенов (5 минут)
  • Rate limiting для /auth/qr/create
  • Очистка устаревших токенов (garbage collection)
  • Переменная BOT_INTERNAL_SECRET в env

Telegram бот

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

Фронтенд (Angular)

  • AuthService.createQrToken()POST /auth/qr/create
  • AuthService.pollQrToken(token)GET /auth/qr/poll
  • QrPollResponse интерфейс в auth.model.ts
  • TelegramLoginComponentinitQrLogin() при открытии диалога
  • QR loading/ready/expired состояния
  • refreshQr() — обновление QR после истечения
  • Ключ перевода auth.qrExpired

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

  • Тест 1: curl POST /auth/qr/create → токен
  • Тест 2: curl GET /auth/qr/pollpending
  • Тест 3: curl POST /auth/qr/confirmok
  • Тест 4: curl GET /auth/qr/pollconfirmed + cookie
  • Тест 5: E2E — десктоп → QR → телефон → Telegram → авторизован

Порядок реализации

  1. Бэкенд: хранилище токенов + 3 эндпоинта (~2-3 часа)
  2. Бот: обработка /start login_ + вызов confirm (~1 час)
  3. Фронтенд: обновить сервис и компонент (~1 час)
  4. Тестирование: curl + E2E (~30 мин)

Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста.