From db781fd871583f30426b650d6493966030159121 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 25 Mar 2026 15:32:50 +0400 Subject: [PATCH] qr login with telegram --- docs/QR_LOGIN_IMPLEMENTATION_RU.md | 1040 +++++++++++++++++ .../telegram-login.component.html | 42 +- .../telegram-login.component.scss | 36 + .../telegram-login.component.ts | 100 +- src/app/i18n/en.ts | 1 + src/app/i18n/hy.ts | 3 +- src/app/i18n/ru.ts | 1 + src/app/i18n/translations.ts | 1 + src/app/models/auth.model.ts | 5 + src/app/services/auth.service.ts | 30 +- 10 files changed, 1234 insertions(+), 25 deletions(-) create mode 100644 docs/QR_LOGIN_IMPLEMENTATION_RU.md diff --git a/docs/QR_LOGIN_IMPLEMENTATION_RU.md b/docs/QR_LOGIN_IMPLEMENTATION_RU.md new file mode 100644 index 0000000..9488837 --- /dev/null +++ b/docs/QR_LOGIN_IMPLEMENTATION_RU.md @@ -0,0 +1,1040 @@ +# Авторизация через 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 мин) + +Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста. diff --git a/src/app/components/telegram-login/telegram-login.component.html b/src/app/components/telegram-login/telegram-login.component.html index 42f3207..efbc8b4 100644 --- a/src/app/components/telegram-login/telegram-login.component.html +++ b/src/app/components/telegram-login/telegram-login.component.html @@ -31,13 +31,41 @@

{{ 'auth.orScanQr' | translate }}

-
- QR Code -
+ + @switch (qrStatus()) { + @case ('loading') { +
+
+
+ } + @case ('ready') { +
+ QR Code +
+ } + @case ('expired') { +
+ + + + + {{ 'auth.qrExpired' | translate }} +
+ } + @case ('error') { +
+ QR Code +
+ } + }
diff --git a/src/app/components/telegram-login/telegram-login.component.scss b/src/app/components/telegram-login/telegram-login.component.scss index 2b92f22..2e7704d 100644 --- a/src/app/components/telegram-login/telegram-login.component.scss +++ b/src/app/components/telegram-login/telegram-login.component.scss @@ -122,6 +122,42 @@ h2 { display: block; border-radius: 4px; } + + &.qr-loading { + align-items: center; + justify-content: center; + width: 204px; + 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 { + 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; + } + } } } diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts index d726320..20287fe 100644 --- a/src/app/components/telegram-login/telegram-login.component.ts +++ b/src/app/components/telegram-login/telegram-login.component.ts @@ -1,6 +1,8 @@ -import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core'; import { AuthService } from '../../services/auth.service'; +import { CartService } from '../../services/cart.service'; import { TranslatePipe } from '../../i18n/translate.pipe'; +import { getDiscountedPrice } from '../../utils/item.utils'; @Component({ selector: 'app-telegram-login', @@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe'; styleUrls: ['./telegram-login.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class TelegramLoginComponent implements OnInit, OnDestroy { +export class TelegramLoginComponent implements OnDestroy { private authService = inject(AuthService); + private cartService = inject(CartService); showDialog = this.authService.showLoginDialog; status = this.authService.status; + loginUrl = signal(''); + qrToken = signal(''); + qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading'); + encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); private pollTimer?: ReturnType; - ngOnInit(): void { - this.loginUrl.set(this.authService.getTelegramLoginUrl()); + constructor() { + effect(() => { + if (this.showDialog()) { + this.initQrLogin(); + } else { + this.stopPolling(); + } + }); } ngOnDestroy(): void { @@ -31,32 +44,87 @@ export class TelegramLoginComponent implements OnInit, OnDestroy { this.stopPolling(); } - /** Open Telegram login link and start polling for session */ openTelegramLogin(): void { window.open(this.loginUrl(), '_blank'); - this.startPolling(); + if (!this.pollTimer) { + this.startPolling(this.qrToken()); + } } - /** Start polling the backend to detect when user completes Telegram auth */ - private startPolling(): void { + refreshQr(): void { this.stopPolling(); - // Check every 3 seconds for up to 5 minutes + this.initQrLogin(); + } + + private 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: () => { + this.loginUrl.set(this.authService.getTelegramLoginUrl()); + this.qrStatus.set('error'); + } + }); + } + + private startPolling(token: string): void { + this.stopPolling(); + if (!token) return; + let checks = 0; this.pollTimer = setInterval(() => { checks++; - if (checks > 100) { // 100 * 3s = 5 min + if (checks > 100) { this.stopPolling(); + this.qrStatus.set('expired'); return; } - this.authService.checkSession(); - // If authenticated, stop polling and close dialog - if (this.authService.isAuthenticated()) { - this.stopPolling(); - this.authService.hideLogin(); - } + + this.authService.pollQrToken(token).subscribe({ + next: (res) => { + switch (res.status) { + case 'confirmed': + this.stopPolling(); + if (res.session) { + this.syncCartAndComplete(res.session.sessionId); + } else { + this.authService.onTelegramLoginComplete(); + } + break; + case 'expired': + this.stopPolling(); + this.qrStatus.set('expired'); + break; + } + }, + error: () => { + // Network error — keep polling + } + }); }, 3000); } + private syncCartAndComplete(sessionId: string): void { + const cartItems = this.cartService.items().map(item => ({ + itemID: item.itemID, + quantity: item.quantity, + colour: item.colour || '', + size: item.size || '', + price: item.discount > 0 + ? item.price * (1 - item.discount / 100) + : item.price, + })); + + this.authService.syncCart(sessionId, cartItems).subscribe(() => { + this.authService.onTelegramLoginComplete(); + }); + } + private stopPolling(): void { if (this.pollTimer) { clearInterval(this.pollTimer); diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 512ddc6..01a04a9 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -205,5 +205,6 @@ export const en: Translations = { loginWithTelegram: 'Log in with Telegram', orScanQr: 'Or scan the QR code', loginNote: 'You will be redirected back after login', + qrExpired: 'QR code expired. Click to refresh', }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 9f52e59..177a283 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1,4 +1,4 @@ -import { Translations } from './translations'; +import { Translations } from './translations'; export const hy: Translations = { header: { @@ -205,5 +205,6 @@ export const hy: Translations = { loginWithTelegram: 'Մուտք Telegram-ով', orScanQr: 'Կամ սքանավորեք QR կոդը', loginNote: 'Մուտքից հետո դուք կվերաուղղվեք', + qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 7e8c97c..44a452c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -205,5 +205,6 @@ export const ru: Translations = { loginWithTelegram: 'Войти через Telegram', orScanQr: 'Или отсканируйте QR-код', loginNote: 'После входа вы будете перенаправлены обратно', + qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index b707b98..4899df8 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -203,5 +203,6 @@ export interface Translations { loginWithTelegram: string; orScanQr: string; loginNote: string; + qrExpired: string; }; } diff --git a/src/app/models/auth.model.ts b/src/app/models/auth.model.ts index 3ffde95..0f1cc0d 100644 --- a/src/app/models/auth.model.ts +++ b/src/app/models/auth.model.ts @@ -17,4 +17,9 @@ export interface TelegramAuthData { hash: string; } +export interface QrPollResponse { + status: 'pending' | 'confirmed' | 'expired'; + session?: AuthSession; +} + export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated'; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 0b5cbec..de5f470 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,7 +1,7 @@ import { Injectable, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, catchError, map, tap } from 'rxjs'; -import { AuthSession, AuthStatus } from '../models/auth.model'; +import { AuthSession, AuthStatus, QrPollResponse } from '../models/auth.model'; import { environment } from '../../environments/environment'; @Injectable({ @@ -82,6 +82,34 @@ export class AuthService { return this.getTelegramLoginUrl(); } + /** Create a one-time QR login token via backend */ + createQrToken(): Observable<{ token: string; url: string }> { + return this.http.post<{ token: string; url: string }>( + `${this.apiUrl}/auth/qr/create`, + {}, + { withCredentials: true } + ); + } + + /** Poll the QR token status (pending → confirmed / expired) */ + pollQrToken(token: string): Observable { + return this.http.get( + `${this.apiUrl}/auth/qr/poll`, + { + params: { token }, + withCredentials: true, + } + ); + } + + /** Sync local cart to the backend session after login */ + syncCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable { + if (!items.length) return of(null); + return this.http.post(`${this.apiUrl}/websession/${sessionId}`, items, { + withCredentials: true, + }).pipe(catchError(() => of(null))); + } + /** Show login dialog (called when user tries to pay without being logged in) */ requestLogin(): void { this.showLoginSignal.set(true);