1041 lines
38 KiB
Markdown
1041 lines
38 KiB
Markdown
# Авторизация через Telegram QR-код — Подробный план реализации
|
||
|
||
> **Проблема**: текущий QR-код содержит ссылку `https://t.me/Bot?start=auth_...`. Когда пользователь сканирует его телефоном, callback (`/auth/telegram/callback`) открывается в **браузере телефона**, а не в десктопном браузере где открыт маркетплейс. Cookie попадает на телефон → десктоп так и не авторизуется.
|
||
|
||
> **Решение**: QR-логин должен работать по **polling-схеме** — фронтенд генерирует одноразовый токен, показывает его в QR, и поллит бэкенд. Бот сообщает бэкенду "этот токен подтверждён пользователем X" напрямую, без участия браузера телефона.
|
||
|
||
---
|
||
|
||
## Оглавление
|
||
|
||
1. [Как это будет работать (схема)](#1-как-это-будет-работать)
|
||
2. [Что нужно изменить на бэкенде (Go)](#2-бэкенд-go--4-новых-эндпоинта)
|
||
3. [Что нужно изменить в Telegram боте](#3-telegram-бот--обработка-start-login_token)
|
||
4. [Что нужно изменить на фронтенде (Angular)](#4-фронтенд-angular--изменения)
|
||
5. [Хранилище auth_tokens](#5-хранилище-auth-tokens)
|
||
6. [Безопасность](#6-безопасность)
|
||
7. [Тестирование](#7-тестирование)
|
||
8. [Чеклист](#8-чеклист)
|
||
|
||
---
|
||
|
||
## 1. Как это будет работать
|
||
|
||
### Общая схема
|
||
|
||
```
|
||
ДЕСКТОП БРАУЗЕР СЕРВЕР (Go) TELEGRAM
|
||
═══════════════ ═══════════ ════════
|
||
│ │ │
|
||
│ 1. POST /auth/qr/create │ │
|
||
│ ─────────────────────────────> │ │
|
||
│ │ │
|
||
│ { token: "abc123", url: "..." } │ │
|
||
│ <───────────────────────────── │ │
|
||
│ │ │
|
||
│ 2. Показать QR с URL: │ │
|
||
│ t.me/Bot?start=login_abc123 │ │
|
||
│ │ │
|
||
│ ПОЛЬЗОВАТЕЛЬ СКАНИРУЕТ QR ТЕЛЕФОНОМ
|
||
│ │ │
|
||
│ │ 3. /start login_abc123 │
|
||
│ │ <────────────────────────│
|
||
│ │ │
|
||
│ │ Бот: createSession() │
|
||
│ │ Бот: linkTokenToSession│
|
||
│ │ Бот: "✅ Вы вошли!" │
|
||
│ │ ────────────────────────>│
|
||
│ │ │
|
||
│ 4. GET /auth/qr/poll?token=abc │ │
|
||
│ (каждые 2-3 сек) │ │
|
||
│ ─────────────────────────────> │ │
|
||
│ │ │
|
||
│ { status: "confirmed", │ │
|
||
│ session: { sessionId, ... } } │ │
|
||
│ + Set-Cookie: dx_session=... │ │
|
||
│ <───────────────────────────── │ │
|
||
│ │ │
|
||
│ 5. Готово! Cookie установлена, │ │
|
||
│ пользователь авторизован │ │
|
||
```
|
||
|
||
### Ключевое отличие от старой схемы
|
||
|
||
| | Старая схема (не работает) | Новая схема (polling) |
|
||
|---|---|---|
|
||
| QR содержит | `t.me/Bot?start=auth_CALLBACK_URL` | `t.me/Bot?start=login_TOKEN` |
|
||
| Кто получает cookie | Браузер **телефона** (бесполезно) | Браузер **десктопа** (через poll) |
|
||
| Участие телефонного браузера | Да — callback открывается в нём | Нет — всё происходит внутри Telegram |
|
||
| Как десктоп узнаёт об авторизации | Никак ❌ | Поллинг `/auth/qr/poll` ✅ |
|
||
|
||
---
|
||
|
||
## 2. Бэкенд (Go) — 4 новых эндпоинта
|
||
|
||
### 2.1. Хранилище auth-токенов
|
||
|
||
Нужна **вторая** структура (помимо сессий) — одноразовые токены для 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"`
|
||
}
|
||
```
|
||
|
||
**Время жизни:** 5 минут (этого достаточно чтобы достать телефон и отсканировать).
|
||
|
||
**Где хранить:**
|
||
- **Redis** (prod): `SET auth_token:{token} {json} EX 300`
|
||
- **sync.Map** (dev/MVP): обычная карта с очисткой по таймеру
|
||
|
||
```go
|
||
// Redis вариант:
|
||
func saveAuthToken(token AuthToken) error {
|
||
data, _ := json.Marshal(token)
|
||
return redisClient.Set(ctx, "auth_token:"+token.Token, data, 5*time.Minute).Err()
|
||
}
|
||
|
||
func getAuthToken(token string) (*AuthToken, error) {
|
||
data, err := redisClient.Get(ctx, "auth_token:"+token).Bytes()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var t AuthToken
|
||
json.Unmarshal(data, &t)
|
||
return &t, nil
|
||
}
|
||
```
|
||
|
||
```go
|
||
// sync.Map вариант (для MVP):
|
||
var authTokens sync.Map
|
||
|
||
func saveAuthToken(token AuthToken) {
|
||
authTokens.Store(token.Token, token)
|
||
}
|
||
|
||
func getAuthToken(tokenStr string) (*AuthToken, bool) {
|
||
val, ok := authTokens.Load(tokenStr)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
t := val.(AuthToken)
|
||
if time.Now().After(t.ExpiresAt) {
|
||
authTokens.Delete(tokenStr)
|
||
return nil, false
|
||
}
|
||
return &t, true
|
||
}
|
||
|
||
// Запустить при старте сервера — очистка устаревших токенов
|
||
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
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 2.2. Эндпоинт `POST /auth/qr/create`
|
||
|
||
**Кто вызывает:** фронтенд, когда пользователь открывает диалог логина.
|
||
|
||
**Что делает:** создаёт одноразовый токен и возвращает его + URL для QR.
|
||
|
||
```go
|
||
// POST /auth/qr/create
|
||
func handleQrCreate(w http.ResponseWriter, r *http.Request) {
|
||
// 1. Сгенерировать криптографически безопасный токен
|
||
tokenBytes := make([]byte, 32)
|
||
_, err := rand.Read(tokenBytes)
|
||
if 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. Сформировать URL для QR (ссылка на бота)
|
||
qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)
|
||
|
||
// 5. Ответить
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"token": token,
|
||
"url": qrURL,
|
||
})
|
||
}
|
||
|
||
// Определить бота по origin запроса
|
||
func getBotForOrigin(origin string) string {
|
||
switch {
|
||
case strings.Contains(origin, "novo.market"):
|
||
return "novomarket_bot"
|
||
default:
|
||
return "DexarSupport_bot"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Ответ (200):**
|
||
```json
|
||
{
|
||
"token": "dG9rZW4tYWJj....",
|
||
"url": "https://t.me/DexarSupport_bot?start=login_dG9rZW4tYWJj...."
|
||
}
|
||
```
|
||
|
||
> **Важно**: параметр `start` в Telegram ограничен **64 символами** (base64, только `[A-Za-z0-9_-]`). `login_` = 6 символов, значит сам токен не больше 58 символов. 32 байта в base64url = 43 символа → итого `login_` + 43 = 49 символов ✅ влезает.
|
||
|
||
---
|
||
|
||
### 2.3. Эндпоинт `GET /auth/qr/poll`
|
||
|
||
**Кто вызывает:** фронтенд, каждые 2-3 секунды после показа QR.
|
||
|
||
**Что делает:** проверяет статус токена. Если бот подтвердил — возвращает сессию и ставит cookie.
|
||
|
||
```go
|
||
// GET /auth/qr/poll?token={token}
|
||
func handleQrPoll(w http.ResponseWriter, r *http.Request) {
|
||
tokenStr := r.URL.Query().Get("token")
|
||
if tokenStr == "" {
|
||
http.Error(w, "missing token", 400)
|
||
return
|
||
}
|
||
|
||
// 1. Найти токен
|
||
authToken, ok := getAuthToken(tokenStr)
|
||
if !ok {
|
||
// Токен не найден или истёк
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"status": "expired",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 2. Проверить статус
|
||
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 {
|
||
switch {
|
||
case strings.Contains(origin, "novo.market"):
|
||
return ".novo.market"
|
||
default:
|
||
return ".dexarmarket.ru"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Ответы:**
|
||
|
||
Ожидание:
|
||
```json
|
||
{ "status": "pending" }
|
||
```
|
||
|
||
Подтверждено:
|
||
```json
|
||
{
|
||
"status": "confirmed",
|
||
"session": {
|
||
"sessionId": "550e8400-...",
|
||
"telegramUserId": 123456789,
|
||
"username": "ivan_petrov",
|
||
"displayName": "Иван Петров",
|
||
"active": true,
|
||
"expiresAt": "2026-03-26T14:30:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
Истекло:
|
||
```json
|
||
{ "status": "expired" }
|
||
```
|
||
|
||
---
|
||
|
||
### 2.4. Эндпоинт `POST /auth/qr/confirm` (внутренний, для бота)
|
||
|
||
**Кто вызывает:** Telegram бот, когда пользователь нажал `/start login_{token}`.
|
||
|
||
**Что делает:** привязывает сессию к токену.
|
||
|
||
```go
|
||
// POST /auth/qr/confirm
|
||
// Тело: { "token": "abc...", "telegram_user": { "id": 123, "first_name": "...", ... } }
|
||
func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
|
||
// 1. Проверить что запрос от бота (по секретному ключу)
|
||
botSecret := r.Header.Get("X-Bot-Secret")
|
||
if botSecret != os.Getenv("BOT_INTERNAL_SECRET") {
|
||
http.Error(w, "forbidden", 403)
|
||
return
|
||
}
|
||
|
||
// 2. Прочитать тело
|
||
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
|
||
}
|
||
|
||
// 3. Найти токен
|
||
authToken, ok := getAuthToken(req.Token)
|
||
if !ok || authToken.Status != "pending" {
|
||
http.Error(w, "token not found or already used", 404)
|
||
return
|
||
}
|
||
|
||
// 4. Создать сессию
|
||
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)
|
||
|
||
// 5. Обновить токен — привязать сессию
|
||
authToken.Status = "confirmed"
|
||
authToken.SessionID = session.SessionID
|
||
saveAuthToken(*authToken)
|
||
|
||
// 6. Ответить боту
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"status": "ok",
|
||
})
|
||
}
|
||
```
|
||
|
||
> **Безопасность**: эндпоинт защищён заголовком `X-Bot-Secret` — его знает только бот. Это предотвращает подделку подтверждений.
|
||
|
||
---
|
||
|
||
### 2.5. Добавить новые роуты к серверу
|
||
|
||
```go
|
||
mux := http.NewServeMux()
|
||
|
||
// Существующие эндпоинты
|
||
mux.HandleFunc("GET /items/{id}", handleGetItem)
|
||
mux.HandleFunc("GET /category", handleGetCategories)
|
||
// ... другие ...
|
||
|
||
// 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)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Telegram бот — обработка `/start login_{token}`
|
||
|
||
### Что меняется
|
||
|
||
Раньше бот обрабатывал `/start auth_CALLBACK_URL` — отправлял кнопку с URL.
|
||
|
||
Теперь бот обрабатывает `/start login_{token}` — **сам** вызывает бэкенд и подтверждает авторизацию. Никакой кнопки "Войти на сайт" не нужно!
|
||
|
||
### Новый handleStart
|
||
|
||
```go
|
||
const (
|
||
// URL бэкенда для подтверждения токена (внутренний, не публичный)
|
||
confirmURL = "http://localhost:8080/auth/qr/confirm" // или внутренний адрес
|
||
botInternalSecret = "ваш-секретный-ключ-для-бота" // из env
|
||
)
|
||
|
||
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"):
|
||
// Старый flow через кнопку (оставить для обратной совместимости)
|
||
handleDirectAuth(update, user)
|
||
|
||
default:
|
||
// Обычный /start — приветствие
|
||
sendWelcome(update)
|
||
}
|
||
}
|
||
|
||
func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
|
||
// 1. Подготовить данные пользователя
|
||
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)
|
||
|
||
// 2. Вызвать бэкенд для подтверждения
|
||
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()
|
||
|
||
// 3. Успех — сообщить пользователю
|
||
displayName := user.FirstName
|
||
if user.LastName != "" {
|
||
displayName += " " + user.LastName
|
||
}
|
||
|
||
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
|
||
fmt.Sprintf("✅ Вы вошли на сайт как %s!\n\nМожете вернуться в браузер — страница обновится автоматически.", displayName))
|
||
bot.Send(msg)
|
||
}
|
||
```
|
||
|
||
### Что видит пользователь в Telegram
|
||
|
||
```
|
||
Пользователь: /start login_dG9rZW4tYWJj... (автоматически при открытии ссылки)
|
||
|
||
Бот: ✅ Вы вошли на сайт как Иван Петров!
|
||
Можете вернуться в браузер — страница обновится автоматически.
|
||
```
|
||
|
||
**Никаких дополнительных кнопок нажимать не нужно!** Один скан QR → автоматический вход.
|
||
|
||
---
|
||
|
||
## 4. Фронтенд (Angular) — изменения
|
||
|
||
### 4.1. Изменить `AuthService`
|
||
|
||
Нужно добавить два метода: создание QR-токена и поллинг.
|
||
|
||
**Файл:** `src/app/services/auth.service.ts`
|
||
|
||
**Добавить:**
|
||
|
||
```typescript
|
||
// Создать QR-токен для авторизации
|
||
createQrToken(): Observable<{ token: string; url: string }> {
|
||
return this.http.post<{ token: string; url: string }>(
|
||
`${this.apiUrl}/auth/qr/create`,
|
||
{},
|
||
{ withCredentials: true }
|
||
);
|
||
}
|
||
|
||
// Проверить статус QR-токена
|
||
pollQrToken(token: string): Observable<QrPollResponse> {
|
||
return this.http.get<QrPollResponse>(
|
||
`${this.apiUrl}/auth/qr/poll`,
|
||
{
|
||
params: { token },
|
||
withCredentials: true,
|
||
}
|
||
);
|
||
}
|
||
```
|
||
|
||
**Добавить интерфейс в `auth.model.ts`:**
|
||
|
||
```typescript
|
||
export interface QrPollResponse {
|
||
status: 'pending' | 'confirmed' | 'expired';
|
||
session?: AuthSession;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.2. Изменить `TelegramLoginComponent`
|
||
|
||
**Файл:** `src/app/components/telegram-login/telegram-login.component.ts`
|
||
|
||
Вместо статичной ссылки — при открытии диалога запрашивать одноразовый токен:
|
||
|
||
```typescript
|
||
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||
import { AuthService } from '../../services/auth.service';
|
||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||
|
||
@Component({
|
||
selector: 'app-telegram-login',
|
||
imports: [TranslatePipe],
|
||
templateUrl: './telegram-login.component.html',
|
||
styleUrls: ['./telegram-login.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||
private authService = inject(AuthService);
|
||
|
||
showDialog = this.authService.showLoginDialog;
|
||
status = this.authService.status;
|
||
|
||
loginUrl = signal('');
|
||
qrToken = signal('');
|
||
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
||
|
||
private pollTimer?: ReturnType<typeof setInterval>;
|
||
|
||
ngOnInit(): void {
|
||
// Будет вызвано при создании компонента
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this.stopPolling();
|
||
}
|
||
|
||
/** Вызывается когда диалог открывается — запросить свежий QR-токен */
|
||
initQrLogin(): void {
|
||
this.qrStatus.set('loading');
|
||
this.authService.createQrToken().subscribe({
|
||
next: (res) => {
|
||
this.loginUrl.set(res.url);
|
||
this.qrToken.set(res.token);
|
||
this.qrStatus.set('ready');
|
||
this.startPolling(res.token);
|
||
},
|
||
error: () => {
|
||
// Fallback — старая ссылка (прямой вход через кнопку всё ещё работает)
|
||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||
this.qrStatus.set('error');
|
||
}
|
||
});
|
||
}
|
||
|
||
close(): void {
|
||
this.authService.hideLogin();
|
||
this.stopPolling();
|
||
}
|
||
|
||
openTelegramLogin(): void {
|
||
window.open(this.loginUrl(), '_blank');
|
||
// Если QR-поллинг уже идёт — не запускать второй
|
||
if (!this.pollTimer) {
|
||
this.startPolling(this.qrToken());
|
||
}
|
||
}
|
||
|
||
refreshQr(): void {
|
||
this.stopPolling();
|
||
this.initQrLogin();
|
||
}
|
||
|
||
private startPolling(token: string): void {
|
||
this.stopPolling();
|
||
if (!token) return;
|
||
|
||
let checks = 0;
|
||
this.pollTimer = setInterval(() => {
|
||
checks++;
|
||
|
||
// Таймаут через 5 минут (100 * 3 сек = 300 сек)
|
||
if (checks > 100) {
|
||
this.stopPolling();
|
||
this.qrStatus.set('expired');
|
||
return;
|
||
}
|
||
|
||
this.authService.pollQrToken(token).subscribe({
|
||
next: (res) => {
|
||
switch (res.status) {
|
||
case 'confirmed':
|
||
// Успех! Cookie уже установлена ответом poll
|
||
this.stopPolling();
|
||
this.authService.onTelegramLoginComplete();
|
||
break;
|
||
case 'expired':
|
||
this.stopPolling();
|
||
this.qrStatus.set('expired');
|
||
break;
|
||
// 'pending' — продолжаем поллинг
|
||
}
|
||
},
|
||
error: () => {
|
||
// Сетевая ошибка — продолжаем поллинг
|
||
}
|
||
});
|
||
}, 3000);
|
||
}
|
||
|
||
private stopPolling(): void {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer);
|
||
this.pollTimer = undefined;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.3. Изменить шаблон (HTML)
|
||
|
||
**Файл:** `src/app/components/telegram-login/telegram-login.component.html`
|
||
|
||
```html
|
||
@if (showDialog()) {
|
||
<div class="login-overlay" (click)="close()">
|
||
<div class="login-dialog" (click)="$event.stopPropagation()">
|
||
<button class="close-btn" (click)="close()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 6L6 18M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div class="login-icon">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||
</svg>
|
||
</div>
|
||
|
||
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
||
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
||
|
||
@if (status() === 'checking') {
|
||
<div class="login-status checking">
|
||
<div class="spinner"></div>
|
||
<span>{{ 'auth.checking' | translate }}</span>
|
||
</div>
|
||
} @else {
|
||
<!-- Кнопка "Войти через Telegram" (прямой переход) -->
|
||
<button class="telegram-btn" (click)="openTelegramLogin()">
|
||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||
</svg>
|
||
{{ 'auth.loginWithTelegram' | translate }}
|
||
</button>
|
||
|
||
<!-- QR-секция -->
|
||
<div class="qr-section">
|
||
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
||
|
||
@switch (qrStatus()) {
|
||
@case ('loading') {
|
||
<div class="qr-container qr-loading">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
}
|
||
@case ('ready') {
|
||
<div class="qr-container">
|
||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||
alt="QR Code"
|
||
width="180"
|
||
height="180"
|
||
loading="eager" />
|
||
</div>
|
||
}
|
||
@case ('expired') {
|
||
<div class="qr-container qr-expired" (click)="refreshQr()">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 4v6h6M23 20v-6h-6"/>
|
||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||
</svg>
|
||
<span>{{ 'auth.qrExpired' | translate }}</span>
|
||
</div>
|
||
}
|
||
@case ('error') {
|
||
<!-- Fallback: показать QR со старой ссылкой (прямой вход) -->
|
||
<div class="qr-container">
|
||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||
alt="QR Code"
|
||
width="180"
|
||
height="180"
|
||
loading="lazy" />
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
```
|
||
|
||
Добавить в компонент computed для `encodedQrUrl`:
|
||
|
||
```typescript
|
||
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
||
```
|
||
|
||
---
|
||
|
||
### 4.4. Инициализация QR при открытии диалога
|
||
|
||
В `TelegramLoginComponent` нужно реагировать на `showDialog()` — когда он становится `true`, запросить свежий QR:
|
||
|
||
```typescript
|
||
// В ngOnInit:
|
||
ngOnInit(): void {
|
||
// Подписаться на изменение showDialog
|
||
// Используем effect для отслеживания сигнала
|
||
effect(() => {
|
||
if (this.showDialog()) {
|
||
this.initQrLogin();
|
||
} else {
|
||
this.stopPolling();
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
Не забудьте импортировать `effect` из `@angular/core` и `computed`.
|
||
|
||
---
|
||
|
||
### 4.5. Обновить стили
|
||
|
||
Добавить в `telegram-login.component.scss`:
|
||
|
||
```scss
|
||
.qr-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 204px; // 180 + 24 padding
|
||
height: 204px;
|
||
|
||
.spinner {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 3px solid #e0e0e0;
|
||
border-top-color: var(--accent-color, #497671);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
}
|
||
|
||
.qr-expired {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 204px;
|
||
height: 204px;
|
||
cursor: pointer;
|
||
color: var(--text-secondary, #999);
|
||
transition: color 0.2s ease;
|
||
|
||
&:hover {
|
||
color: var(--accent-color, #497671);
|
||
}
|
||
|
||
span {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.6. Добавить новые ключи переводов
|
||
|
||
**Файл:** `src/app/i18n/ru.ts` (и аналогично `en.ts`, `hy.ts`)
|
||
|
||
```typescript
|
||
'auth.qrExpired': 'QR-код устарел. Нажмите чтобы обновить',
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Хранилище auth_tokens — итоговая структура
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Хранилище (Redis / sync.Map / PostgreSQL) │
|
||
│ │
|
||
│ auth_token:{token} │
|
||
│ ├── token: "dG9rZW4tYWJj..." │
|
||
│ ├── status: "pending" | "confirmed" | "expired"│
|
||
│ ├── sessionId: "" → "550e8400-..." (после confirm)│
|
||
│ ├── createdAt: "2026-03-25T12:00:00Z" │
|
||
│ └── expiresAt: "2026-03-25T12:05:00Z" (5 мин) │
|
||
│ │
|
||
│ session:{sessionId} │
|
||
│ ├── sessionId: "550e8400-..." │
|
||
│ ├── telegramUserId: 123456789 │
|
||
│ ├── username: "ivan_petrov" │
|
||
│ ├── displayName: "Иван Петров" │
|
||
│ ├── active: true │
|
||
│ └── expiresAt: "2026-03-26T12:00:00Z" (24ч) │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Безопасность
|
||
|
||
### 6.1. Токен должен быть криптографически случайным
|
||
|
||
```go
|
||
tokenBytes := make([]byte, 32) // 256 бит энтропии
|
||
crypto/rand.Read(tokenBytes)
|
||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
|
||
```
|
||
|
||
**НЕ использовать:** `math/rand`, UUID, timestamp, или предсказуемые значения.
|
||
|
||
### 6.2. Токен одноразовый
|
||
|
||
- После `confirmed` → `deleteAuthToken()` при первом успешном `poll`
|
||
- После 5 минут → автоматическое удаление (TTL)
|
||
- Повторный `poll` после использования → `"expired"`
|
||
|
||
### 6.3. Защита `POST /auth/qr/confirm`
|
||
|
||
Этот эндпоинт вызывается **только ботом**. Защита:
|
||
|
||
```go
|
||
// В .env:
|
||
BOT_INTERNAL_SECRET=случайная-строка-32-символа
|
||
|
||
// В обработчике:
|
||
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
|
||
http.Error(w, "forbidden", 403)
|
||
return
|
||
}
|
||
```
|
||
|
||
**Дополнительно можно:**
|
||
- Ограничить доступ по IP (если бот на том же сервере → только `127.0.0.1`/`localhost`)
|
||
- Использовать внутренний порт недоступный снаружи
|
||
|
||
### 6.4. Rate limiting
|
||
|
||
Ограничить `POST /auth/qr/create`:
|
||
- Не более **5 токенов в минуту** с одного IP
|
||
- Это предотвращает спам-создание токенов
|
||
|
||
```go
|
||
// Простейший лимитер на sync.Map
|
||
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
|
||
}
|
||
```
|
||
|
||
### 6.5. CORS
|
||
|
||
Новые эндпоинты `/auth/qr/*` должны быть включены в тот же CORS middleware что и `/auth/session`. Поскольку они уже под `/auth/` — middleware покроет их автоматически.
|
||
|
||
---
|
||
|
||
## 7. Тестирование
|
||
|
||
### Шаг за шагом
|
||
|
||
**Подготовка:**
|
||
1. Добавить `BOT_INTERNAL_SECRET` в env бота и сервера (одинаковое значение)
|
||
2. Добавить обработку `/start login_` в бота
|
||
3. Добавить 3 эндпоинта на сервер
|
||
4. Обновить фронтенд
|
||
|
||
**Тест 1 — Создание токена:**
|
||
```bash
|
||
curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \
|
||
-H "Origin: https://dexarmarket.ru"
|
||
```
|
||
Ожидаемый ответ:
|
||
```json
|
||
{ "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 секунды диалог логина закрывается → вы авторизованы
|
||
|
||
### Логи для отладки
|
||
|
||
| Проблема | Где смотреть |
|
||
|----------|-------------|
|
||
| QR не показывается | Консоль браузера → `POST /auth/qr/create` отвечает ошибкой? CORS? |
|
||
| QR отсканирован, но ничего не происходит | Бот получил `/start login_...`? Бот смог вызвать `/auth/qr/confirm`? |
|
||
| Бот пишет "❌ QR устарел" | Токен в бэкенде уже expired? 5 минут прошло? |
|
||
| Поллинг говорит "pending" бесконечно | Бот вызвал `confirm`? Проверить логи бота |
|
||
| Поллинг говорит "confirmed" но cookie нет | CORS, `SameSite`, `Secure`, `Domain` — см. шаг 7 старого документа |
|
||
|
||
---
|
||
|
||
## 8. Чеклист
|
||
|
||
### Бэкенд (Go)
|
||
|
||
- [ ] Структура `AuthToken` и функции `save/get/deleteAuthToken`
|
||
- [ ] `POST /auth/qr/create` — генерация токена, возврат URL
|
||
- [ ] `GET /auth/qr/poll?token=...` — проверка статуса, установка cookie при `confirmed`
|
||
- [ ] `POST /auth/qr/confirm` — приём подтверждения от бота (с проверкой `X-Bot-Secret`)
|
||
- [ ] TTL для токенов (5 минут)
|
||
- [ ] Rate limiting для `/auth/qr/create`
|
||
- [ ] Очистка устаревших токенов (garbage collection)
|
||
- [ ] Переменная `BOT_INTERNAL_SECRET` в env
|
||
|
||
### Telegram бот
|
||
|
||
- [ ] Обработка `/start login_{token}` — вызов `POST /auth/qr/confirm`
|
||
- [ ] Сообщение пользователю "✅ Вы вошли" или "❌ QR устарел"
|
||
- [ ] Переменная `BOT_INTERNAL_SECRET` в env бота (совпадает с сервером)
|
||
|
||
### Фронтенд (Angular)
|
||
|
||
- [ ] `AuthService.createQrToken()` → `POST /auth/qr/create`
|
||
- [ ] `AuthService.pollQrToken(token)` → `GET /auth/qr/poll`
|
||
- [ ] `QrPollResponse` интерфейс в `auth.model.ts`
|
||
- [ ] `TelegramLoginComponent` — `initQrLogin()` при открытии диалога
|
||
- [ ] QR loading/ready/expired состояния
|
||
- [ ] `refreshQr()` — обновление QR после истечения
|
||
- [ ] Ключ перевода `auth.qrExpired`
|
||
|
||
### Тестирование
|
||
|
||
- [ ] Тест 1: `curl POST /auth/qr/create` → токен
|
||
- [ ] Тест 2: `curl GET /auth/qr/poll` → `pending`
|
||
- [ ] Тест 3: `curl POST /auth/qr/confirm` → `ok`
|
||
- [ ] Тест 4: `curl GET /auth/qr/poll` → `confirmed` + cookie
|
||
- [ ] Тест 5: E2E — десктоп → QR → телефон → Telegram → авторизован
|
||
|
||
---
|
||
|
||
## Порядок реализации
|
||
|
||
1. **Бэкенд:** хранилище токенов + 3 эндпоинта (~2-3 часа)
|
||
2. **Бот:** обработка `/start login_` + вызов confirm (~1 час)
|
||
3. **Фронтенд:** обновить сервис и компонент (~1 час)
|
||
4. **Тестирование:** curl + E2E (~30 мин)
|
||
|
||
Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста.
|