added docs
This commit is contained in:
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal file
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal 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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 (при написании отзывов). Это **не используется для авторизации** — только для отображения. Бэкенду ничего делать не нужно.
|
|
||||||
Reference in New Issue
Block a user