27 KiB
Авторизация через 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.
Хранилище
Структура: Сессия
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-токен (одноразовый)
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 (рекомендуется):
// Сессия
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):
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-токен.
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"
}
Ответ:
{ "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.
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. Привязывает сессию к токену.
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"})
}
Запрос от бота:
{
"token": "dG9rZW4tYWJj...",
"telegram_user": {
"id": 123456789,
"first_name": "Иван",
"last_name": "Петров",
"username": "ivan_petrov"
}
}
4. GET /auth/session
Фронтенд вызывает для проверки текущей сессии. Читает cookie.
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) — фронтенд ожидает точно эти поля:
{
"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). Открывается в браузере.
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
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. Бэкенд обязан вернуть правильные заголовки.
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.
Роутинг
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
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-логин (основной)
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
}
Прямой вход (кнопка, для обратной совместимости)
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)
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}
Тело — массив:
[
{
"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}) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина.
Безопасность
Криптографический токен
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
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:
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
}
Переменные окружения
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. Создание токена:
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. Поллинг (до подтверждения):
curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "pending" }
3. Подтверждение (имитация бота):
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. Поллинг (после подтверждения):
curl -v "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "confirmed", "session": {...} } + Set-Cookie: dx_session=...
5. E2E:
- Открыть маркетплейс → добавить товар в корзину
- Нажать "Оформить заказ" → появляется диалог с QR
- Отсканировать QR телефоном → Telegram → бот: "✅ Вы вошли!"
- Через 3 сек диалог закрывается → авторизован
- Корзина синхронизирована (
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 при confirmedPOST /auth/qr/confirm— приём от бота сX-Bot-SecretGET /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