added docs

This commit is contained in:
sdarbinyan
2026-03-25 15:42:27 +04:00
parent db781fd871
commit 6de461473e
3 changed files with 824 additions and 1529 deletions

View File

@@ -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