diff --git a/docs/BACKEND_AUTH_INTEGRATION.md b/docs/BACKEND_AUTH_INTEGRATION.md new file mode 100644 index 0000000..ac3c6fd --- /dev/null +++ b/docs/BACKEND_AUTH_INTEGRATION.md @@ -0,0 +1,824 @@ +# Авторизация через 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`. + +--- + +## Хранилище + +### Структура: Сессия + +```go +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-токен (одноразовый) + +```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"` +} +``` + +**TTL:** 5 минут. + +### Варианты хранения + +**Redis (рекомендуется):** +```go +// Сессия +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):** +```go +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-токен. + +```go +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" +} +``` + +**Ответ:** +```json +{ "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. + +```go +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. Привязывает сессию к токену. + +```go +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"}) +} +``` + +**Запрос от бота:** +```json +{ + "token": "dG9rZW4tYWJj...", + "telegram_user": { + "id": 123456789, + "first_name": "Иван", + "last_name": "Петров", + "username": "ivan_petrov" + } +} +``` + +--- + +### 4. `GET /auth/session` + +Фронтенд вызывает для проверки текущей сессии. Читает cookie. + +```go +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)** — фронтенд ожидает **точно эти поля**: + +```json +{ + "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). Открывается в браузере. + +```go +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` + +```go +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"}`)) +} +``` + +--- + +## Cookie-параметры + +| Параметр | Значение | Почему | +|----------|----------|--------| +| `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`. Бэкенд обязан вернуть правильные заголовки. + +```go +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. + +--- + +## Роутинг + +```go +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` + +```go +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-логин (основной) + +```go +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 +} +``` + +### Прямой вход (кнопка, для обратной совместимости) + +```go +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) + +```go +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} +``` + +Тело — массив: +```json +[ + { + "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}`) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина. + +--- + +## Безопасность + +### Криптографический токен +```go +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` +```go +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: +```go +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 +} +``` + +--- + +## Переменные окружения + +```env +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. Создание токена:** +```bash +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. Поллинг (до подтверждения):** +```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 сек диалог закрывается → авторизован +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 diff --git a/docs/QR_LOGIN_IMPLEMENTATION_RU.md b/docs/QR_LOGIN_IMPLEMENTATION_RU.md deleted file mode 100644 index 9488837..0000000 --- a/docs/QR_LOGIN_IMPLEMENTATION_RU.md +++ /dev/null @@ -1,1040 +0,0 @@ -# Авторизация через 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/docs/TELEGRAM_BOT_INTEGRATION.md b/docs/TELEGRAM_BOT_INTEGRATION.md deleted file mode 100644 index 7bc5761..0000000 --- a/docs/TELEGRAM_BOT_INTEGRATION.md +++ /dev/null @@ -1,489 +0,0 @@ -# Telegram-бот авторизация — Руководство по реализации - -> **Цель документа**: пошаговая инструкция для Go-разработчика. После выполнения всех шагов — авторизация через Telegram-бота будет работать. - ---- - -## Статус - -| Компонент | Готов? | -|-----------|--------| -| Frontend (Angular) — диалог входа, поллинг, сессия, корзина | ✅ Готов | -| Telegram бот (обработка `/start`) | ❌ Нужно сделать | -| Backend — 3 HTTP-эндпоинта авторизации | ❌ Нужно сделать | -| Хранилище сессий (Redis / PostgreSQL / in-memory) | ❌ Нужно сделать | -| CORS для cookie-based запросов | ❌ Нужно настроить | - ---- - -## Архитектура - -``` -┌──────────┐ 1. t.me/Bot?start=auth_token ┌──────────────┐ -│ │ ──────────────────────────────────────>│ Telegram │ -│ Браузер │ │ (облако) │ -│ │ 2. /start auth_{token} │ │ -│ │ └──────┬───────┘ -│ │ │ -│ │ v -│ │ ┌──────────────┐ -│ │ 4. GET /auth/session │ Go Backend │ -│ │ (Cookie) — поллинг 3 сек │ │ -│ │ ──────────────────────────────────────>│ - Bot handler│ -│ │ │ - Auth API │ -│ │ 5. { sessionId, displayName, ... } │ - Sessions │ -│ │ <─────────────────────────────────────│ │ -│ │ └──────┬───────┘ -│ │ 3. GET /auth/telegram/callback │ -│ │ + Set-Cookie: session=... ◄──────┘ -│ │ <───────────────────────────────────── -└──────────┘ -``` - ---- - -## Шаг 1: Создать бота в @BotFather - -1. Открыть https://t.me/BotFather -2. `/newbot` (или использовать существующего) -3. Сохранить **BOT_TOKEN** — он понадобится в шаге 3 - -Нужны два бота: - -| Бренд | Username бота | Домен фронтенда | API сервер | -|-------|---------------|------------------|------------| -| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` | -| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` | - ---- - -## Шаг 2: Хранилище сессий - -Нужна таблица или Map для хранения сессий: - -```go -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"` -} -``` - -Можно использовать: -- **Redis** (рекомендуется для продакшена) — `SET session:{id} {json} EX 86400` -- **PostgreSQL** — таблица `auth_sessions` -- **sync.Map** — для MVP/тестирования (не переживёт рестарт) - -**Время жизни сессии:** 24 часа (фронтенд перепроверяет за 60 сек до `expiresAt`). - ---- - -## Шаг 3: Обработчик команды `/start` в боте - -### Что получает бот - -Когда пользователь кликает ссылку: -``` -https://t.me/DexarSupport_bot?start=auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback -``` - -Бот получает Update с `Message.Text`: -``` -/start auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback -``` - -### Что должен сделать бот - -```go -func handleStart(update tgbotapi.Update) { - text := update.Message.Text - user := update.Message.From - - // 1. Извлечь callback URL - if !strings.HasPrefix(text, "/start auth_") { - // Обычный /start — показать приветствие - return - } - callbackURL, _ := url.QueryUnescape(strings.TrimPrefix(text, "/start auth_")) - - // 2. Создать сессию - 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) // сохранить в Redis/DB - - // 3. Отправить пользователю кнопку "Войти" - // Кнопка открывает callback URL с токеном сессии - loginURL := callbackURL + "?token=" + session.SessionID - - msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:") - msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL), - ), - ) - bot.Send(msg) -} - -func buildDisplayName(user *tgbotapi.User) string { - name := user.FirstName - if user.LastName != "" { - name += " " + user.LastName - } - return name -} -``` - -> **Важно**: Telegram ограничивает параметр `start` до 64 символов. URL callback'а ~62 символа — проходит впритык. Альтернатива — не передавать callback URL в параметре, а захардкодить его в боте (рекомендуется). - -### Рекомендация: захардкодить callback URL - -```go -const callbackURL = "https://api.dexarmarket.ru:445/auth/telegram/callback" - -func handleStart(update tgbotapi.Update) { - text := update.Message.Text - if !strings.HasPrefix(text, "/start auth") { - return - } - // ... создать сессию ... - loginURL := callbackURL + "?token=" + session.SessionID - // ... отправить кнопку ... -} -``` - -Тогда фронтенд может упростить ссылку до: -``` -https://t.me/DexarSupport_bot?start=auth -``` - ---- - -## Шаг 4: HTTP-эндпоинт `GET /auth/telegram/callback` - -Этот URL открывается в **браузере пользователя** (по клику на кнопку в боте). - -```go -// GET /auth/telegram/callback?token={sessionId} -func handleTelegramCallback(w http.ResponseWriter, r *http.Request) { - token := r.URL.Query().Get("token") - if token == "" { - http.Error(w, "missing token", 400) - return - } - - // 1. Проверить что сессия существует - session, err := getSession(token) - if err != nil || !session.Active { - http.Error(w, "invalid or expired token", 401) - return - } - - // 2. Установить HttpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "dx_session", - Value: session.SessionID, - Path: "/", - HttpOnly: true, - Secure: true, // обязательно для HTTPS - SameSite: http.SameSiteNoneMode, // кросс-доменные запросы - MaxAge: 86400, // 24 часа - Domain: ".dexarmarket.ru", // доступна для поддоменов - }) - - // 3. Редирект на сайт - http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound) -} -``` - -### Про SameSite и Domain - -| Параметр | Значение | Почему | -|----------|----------|--------| -| `SameSite` | `None` | Frontend на `dexarmarket.ru` шлёт запросы к `api.dexarmarket.ru:445` — разные origins | -| `Secure` | `true` | Обязательно при `SameSite=None` | -| `Domain` | `.dexarmarket.ru` | Cookie доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` | -| `HttpOnly` | `true` | Недоступна из JavaScript — защита от XSS | - ---- - -## Шаг 5: HTTP-эндпоинт `GET /auth/session` - -Фронтенд вызывает каждые 3 секунды с `withCredentials: true` (браузер подставляет cookie). - -```go -// GET /auth/session -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) - -Фронтенд ожидает **точно эти поля**: - -```json -{ - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "telegramUserId": 123456789, - "username": "ivan_petrov", - "displayName": "Иван Петров", - "active": true, - "expiresAt": "2026-03-25T14:30:00Z" -} -``` - -| Поле | Тип | Обязательно | Примечание | -|------|-----|-------------|------------| -| `sessionId` | string (UUID) | да | Используется для websession эндпоинтов покупки | -| `telegramUserId` | number | да | Telegram user ID | -| `username` | string / null | нет | Telegram @username (может отсутствовать) | -| `displayName` | string | да | "Имя Фамилия" — показывается в UI | -| `active` | boolean | да | `false` = сессия истекла | -| `expiresAt` | string (ISO 8601) | да | Время истечения. Фронтенд перепроверяет за 60 сек до | - -### Ответ при ошибке (401) - -Любой HTTP статус не-200 → фронтенд считает "не авторизован". - ---- - -## Шаг 6: HTTP-эндпоинт `POST /auth/logout` - -```go -// POST /auth/logout -func handleLogout(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("dx_session") - if err == nil { - deleteSession(cookie.Value) - } - - // Очистить cookie - 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"}`)) -} -``` - ---- - -## Шаг 7: CORS - -Фронтенд шлёт запросы с `withCredentials: true` (cookie). Бэкенд **обязан** вернуть правильные CORS-заголовки. - -```go -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) // НЕ "*" — запрещено с credentials - 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. - ---- - -## Шаг 8: Роутинг — добавить к существующему API - -Эти 3 эндпоинта добавляются к существующему Go серверу на `api.dexarmarket.ru:445`: - -```go -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) - -// НОВЫЕ — авторизация -mux.HandleFunc("GET /auth/session", handleGetSession) -mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback) -mux.HandleFunc("POST /auth/logout", handleLogout) - -// Обернуть в CORS middleware -handler := corsMiddleware(mux) -http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler) -``` - ---- - -## Шаг 9: Запуск Telegram бота - -Бот может работать в режиме **long polling** (проще) или **webhook** (production). - -### Long Polling (для начала) - -```go -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) - } - } -} -``` - -### Переменные окружения - -```env -BOT_TOKEN=123456:ABC-DEF... # от @BotFather -CALLBACK_URL=https://api.dexarmarket.ru:445/auth/telegram/callback -FRONTEND_URL=https://dexarmarket.ru -SESSION_TTL=24h -REDIS_URL=localhost:6379 # если используете Redis -``` - ---- - -## Шаг 10: Проверка — как тестировать - -### 1. Запустить бот и сервер - -```bash -BOT_TOKEN=... go run . -``` - -### 2. Открыть маркетплейс, нажать "Войти" - -Фронтенд откроет `https://t.me/DexarSupport_bot?start=auth_...` - -### 3. В Telegram — нажать "Start" → кнопку "Войти на сайт" - -Бот создаёт сессию, кнопка ведёт на `/auth/telegram/callback?token=...` - -### 4. Браузер получает cookie, редиректит на маркетплейс - -### 5. Фронтенд (поллинг каждые 3 сек) находит сессию → диалог закрывается - -### Логи для отладки - -Что проверять если не работает: - -| Проблема | Где смотреть | -|----------|-------------| -| Бот не отвечает | `BOT_TOKEN` правильный? Бот получает updates? | -| Кнопка "Войти" не открывается | URL `/auth/telegram/callback` доступен? SSL? | -| Cookie не устанавливается | `SameSite`, `Secure`, `Domain` правильные? HTTPS? | -| Фронтенд не находит сессию | CORS настроен? `Access-Control-Allow-Credentials: true`? | -| Сессия не находится | Хранилище работает? TTL не слишком маленький? | - ---- - -## Полный чеклист - -- [ ] Бот создан в @BotFather, токен получен -- [ ] `BOT_TOKEN` сохранён в переменных окружения сервера -- [ ] Хранилище сессий настроено (Redis / PostgreSQL / sync.Map) -- [ ] Обработчик `/start auth_...` в боте создаёт сессию и отправляет inline-кнопку -- [ ] `GET /auth/telegram/callback?token=...` устанавливает HttpOnly cookie и редиректит -- [ ] `GET /auth/session` читает cookie и возвращает JSON сессии (или 401) -- [ ] `POST /auth/logout` удаляет сессию и cookie -- [ ] CORS настроен для `dexarmarket.ru` / `novo.market` с `credentials: true` -- [ ] SSL сертификат установлен (обязательно для `Secure` cookie) -- [ ] Тест: клик "Войти" → Telegram → кнопка → cookie → сессия найдена ✅ - ---- - -## Как фронтенд использует `sessionId` после авторизации - -После успешного входа `sessionId` из ответа `/auth/session` используется для **покупки**: - -``` -POST /websession/{sessionId} ← синхронизация корзины -POST /websession/{sessionId}/qr ← создание QR для СБП -GET /websession/{sessionId}/{qrId} ← проверка статуса оплаты -``` - -Формат данных корзины: -```json -[ - { - "itemID": 123, - "quantity": 2, - "colour": "#ff0000", - "size": "XL", - "price": 1500 - } -] -``` - -> Цвет приходит от бэкенда как `0xff0000`, фронтенд конвертирует в `#ff0000`. -> Размер `"default"` — не показывается пользователю. - ---- - -## Дополнительно: Telegram CloudStorage - -Внутри Telegram Mini App корзина дополнительно сохраняется в `CloudStorage` (API Telegram). Это **автоматически** — бэкенду ничего делать не нужно. Работает только когда сайт открыт через Telegram. - -## Дополнительно: `initDataUnsafe` - -Фронтенд читает `window.Telegram.WebApp.initDataUnsafe.user` для отображения имени пользователя в UI (при написании отзывов). Это **не используется для авторизации** — только для отображения. Бэкенду ничего делать не нужно.