Files
marketplaces/docs/QR_LOGIN_IMPLEMENTATION_RU.md

1041 lines
38 KiB
Markdown
Raw Normal View History

2026-03-25 15:32:50 +04:00
# Авторизация через 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 мин)
Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста.