# Авторизация через Telegram QR-код — Подробный план реализации > **Проблема**: текущий QR-код содержит ссылку `https://t.me/Bot?start=auth_...`. Когда пользователь сканирует его телефоном, callback (`/auth/telegram/callback`) открывается в **браузере телефона**, а не в десктопном браузере где открыт маркетплейс. Cookie попадает на телефон → десктоп так и не авторизуется. > **Решение**: QR-логин должен работать по **polling-схеме** — фронтенд генерирует одноразовый токен, показывает его в QR, и поллит бэкенд. Бот сообщает бэкенду "этот токен подтверждён пользователем X" напрямую, без участия браузера телефона. --- ## Оглавление 1. [Как это будет работать (схема)](#1-как-это-будет-работать) 2. [Что нужно изменить на бэкенде (Go)](#2-бэкенд-go--4-новых-эндпоинта) 3. [Что нужно изменить в Telegram боте](#3-telegram-бот--обработка-start-login_token) 4. [Что нужно изменить на фронтенде (Angular)](#4-фронтенд-angular--изменения) 5. [Хранилище auth_tokens](#5-хранилище-auth-tokens) 6. [Безопасность](#6-безопасность) 7. [Тестирование](#7-тестирование) 8. [Чеклист](#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-логина: ```go 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): обычная карта с очисткой по таймеру ```go // 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 } ``` ```go // 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. ```go // 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):** ```json { "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. ```go // 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" } } ``` **Ответы:** Ожидание: ```json { "status": "pending" } ``` Подтверждено: ```json { "status": "confirmed", "session": { "sessionId": "550e8400-...", "telegramUserId": 123456789, "username": "ivan_petrov", "displayName": "Иван Петров", "active": true, "expiresAt": "2026-03-26T14:30:00Z" } } ``` Истекло: ```json { "status": "expired" } ``` --- ### 2.4. Эндпоинт `POST /auth/qr/confirm` (внутренний, для бота) **Кто вызывает:** Telegram бот, когда пользователь нажал `/start login_{token}`. **Что делает:** привязывает сессию к токену. ```go // 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. Добавить новые роуты к серверу ```go 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 ```go 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` **Добавить:** ```typescript // Создать 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 { return this.http.get( `${this.apiUrl}/auth/qr/poll`, { params: { token }, withCredentials: true, } ); } ``` **Добавить интерфейс в `auth.model.ts`:** ```typescript export interface QrPollResponse { status: 'pending' | 'confirmed' | 'expired'; session?: AuthSession; } ``` --- ### 4.2. Изменить `TelegramLoginComponent` **Файл:** `src/app/components/telegram-login/telegram-login.component.ts` Вместо статичной ссылки — при открытии диалога запрашивать одноразовый токен: ```typescript 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; 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` ```html @if (showDialog()) { } ``` Добавить в компонент computed для `encodedQrUrl`: ```typescript encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); ``` --- ### 4.4. Инициализация QR при открытии диалога В `TelegramLoginComponent` нужно реагировать на `showDialog()` — когда он становится `true`, запросить свежий QR: ```typescript // В ngOnInit: ngOnInit(): void { // Подписаться на изменение showDialog // Используем effect для отслеживания сигнала effect(() => { if (this.showDialog()) { this.initQrLogin(); } else { this.stopPolling(); } }); } ``` Не забудьте импортировать `effect` из `@angular/core` и `computed`. --- ### 4.5. Обновить стили Добавить в `telegram-login.component.scss`: ```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`) ```typescript '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. Токен должен быть криптографически случайным ```go tokenBytes := make([]byte, 32) // 256 бит энтропии crypto/rand.Read(tokenBytes) token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes) ``` **НЕ использовать:** `math/rand`, UUID, timestamp, или предсказуемые значения. ### 6.2. Токен одноразовый - После `confirmed` → `deleteAuthToken()` при первом успешном `poll` - После 5 минут → автоматическое удаление (TTL) - Повторный `poll` после использования → `"expired"` ### 6.3. Защита `POST /auth/qr/confirm` Этот эндпоинт вызывается **только ботом**. Защита: ```go // В .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 - Это предотвращает спам-создание токенов ```go // Простейший лимитер на 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 — Создание токена:** ```bash curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \ -H "Origin: https://dexarmarket.ru" ``` Ожидаемый ответ: ```json { "token": "dG9r...", "url": "https://t.me/DexarSupport_bot?start=login_dG9r..." } ``` **Тест 2 — Поллинг (до подтверждения):** ```bash curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..." ``` Ответ: `{ "status": "pending" }` **Тест 3 — Подтверждение (имитация бота):** ```bash 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 — Поллинг (после подтверждения):** ```bash 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` - [ ] `TelegramLoginComponent` — `initQrLogin()` при открытии диалога - [ ] QR loading/ready/expired состояния - [ ] `refreshQr()` — обновление QR после истечения - [ ] Ключ перевода `auth.qrExpired` ### Тестирование - [ ] Тест 1: `curl POST /auth/qr/create` → токен - [ ] Тест 2: `curl GET /auth/qr/poll` → `pending` - [ ] Тест 3: `curl POST /auth/qr/confirm` → `ok` - [ ] Тест 4: `curl GET /auth/qr/poll` → `confirmed` + cookie - [ ] Тест 5: E2E — десктоп → QR → телефон → Telegram → авторизован --- ## Порядок реализации 1. **Бэкенд:** хранилище токенов + 3 эндпоинта (~2-3 часа) 2. **Бот:** обработка `/start login_` + вызов confirm (~1 час) 3. **Фронтенд:** обновить сервис и компонент (~1 час) 4. **Тестирование:** curl + E2E (~30 мин) Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста.