490 lines
19 KiB
Markdown
490 lines
19 KiB
Markdown
# 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 (при написании отзывов). Это **не используется для авторизации** — только для отображения. Бэкенду ничего делать не нужно.
|