Files
marketplaces/docs/BACKEND_AUTH_INTEGRATION.md
sdarbinyan 6de461473e added docs
2026-03-25 15:42:27 +04:00

825 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Авторизация через 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