Files
marketplaces/docs/QR_LOGIN_IMPLEMENTATION_RU.md
2026-03-25 15:32:50 +04:00

1041 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Авторизация через 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 мин)
Бэкенд и бот можно делать параллельно — они не зависят друг от друга до момента интеграционного теста.