# Авторизация через 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