2 Commits

Author SHA1 Message Date
sdarbinyan
6de461473e added docs 2026-03-25 15:42:27 +04:00
sdarbinyan
db781fd871 qr login with telegram 2026-03-25 15:32:50 +04:00
11 changed files with 1018 additions and 514 deletions

View File

@@ -0,0 +1,824 @@
# Авторизация через Telegram — Backend & Bot
> Всё что нужно Go-разработчику для реализации авторизации.
> Фронтенд **полностью готов** и ждёт эти эндпоинты.
---
## Статус
| Компонент | Готов? |
|-----------|--------|
| Frontend (Angular) — диалог, QR, поллинг, корзина | ✅ Готов |
| Telegram бот (обработка `/start`) | ❌ Нужно |
| Backend — 6 HTTP-эндпоинтов | ❌ Нужно |
| Хранилище сессий + QR-токенов | ❌ Нужно |
| CORS для cookie-based запросов | ❌ Нужно |
---
## Архитектура
Два сценария авторизации:
### Сценарий 1: Прямой вход (кнопка "Войти через Telegram")
Пользователь нажимает кнопку → открывается Telegram → бот выдаёт кнопку "Войти на сайт" → callback ставит cookie → фронтенд поллит `/auth/session`.
### Сценарий 2: QR-логин с десктопа (основной)
```
ДЕСКТОП БРАУЗЕР СЕРВЕР (Go) TELEGRAM
│ │ │
│ 1. POST /auth/qr/create │ │
│ ─────────────────────────────> │ │
│ { token: "abc", url: "..." } │ │
│ <───────────────────────────── │ │
│ │ │
│ 2. Показать QR: │ │
│ t.me/Bot?start=login_abc │ │
│ │ │
│ ПОЛЬЗОВАТЕЛЬ СКАНИРУЕТ ТЕЛЕФОНОМ │
│ │ │
│ │ 3. /start login_abc │
│ │ <────────────────────────│
│ │ │
│ │ Бот → POST /auth/qr/confirm
│ │ Бот → "✅ Вы вошли!" │
│ │ ────────────────────────>│
│ │ │
│ 4. GET /auth/qr/poll?token=abc │ │
│ (каждые 3 сек) │ │
│ ─────────────────────────────> │ │
│ { status: "confirmed", │ │
│ session: {...} } │ │
│ + Set-Cookie: dx_session=... │ │
│ <───────────────────────────── │ │
│ │ │
│ 5. POST /websession/{sessionId} │ │
│ [{ itemID, quantity, ... }] │ ← корзина │
│ ─────────────────────────────> │ │
│ │ │
│ 6. Готово! Авторизован + корзина│ │
```
---
## Бренды и боты
| Бренд | Username бота | Домен фронтенда | API сервер | Cookie Domain |
|-------|---------------|------------------|------------|---------------|
| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` | `.dexarmarket.ru` |
| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` | `.novo.market` |
Бот создаётся через https://t.me/BotFather → `/newbot`. Сохранить `BOT_TOKEN`.
---
## Хранилище
### Структура: Сессия
```go
type Session struct {
SessionID string `json:"sessionId"`
TelegramUserID int64 `json:"telegramUserId"`
Username *string `json:"username"` // может быть null
DisplayName string `json:"displayName"`
Active bool `json:"active"`
ExpiresAt time.Time `json:"expiresAt"`
}
```
**TTL:** 24 часа.
### Структура: QR-токен (одноразовый)
```go
type AuthToken struct {
Token string `json:"token"`
Status string `json:"status"` // "pending" | "confirmed" | "expired"
SessionID string `json:"sessionId"` // заполняется после подтверждения ботом
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
```
**TTL:** 5 минут.
### Варианты хранения
**Redis (рекомендуется):**
```go
// Сессия
redisClient.Set(ctx, "session:"+s.SessionID, json, 24*time.Hour)
// QR-токен
redisClient.Set(ctx, "auth_token:"+t.Token, json, 5*time.Minute)
```
**sync.Map (для MVP):**
```go
var sessions sync.Map
var authTokens sync.Map
// Очистка устаревших токенов — запустить горутину при старте
func cleanupExpiredTokens() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
authTokens.Range(func(key, value any) bool {
t := value.(AuthToken)
if time.Now().After(t.ExpiresAt) {
authTokens.Delete(key)
}
return true
})
}
}
```
---
## HTTP-эндпоинты
### 1. `POST /auth/qr/create`
Фронтенд вызывает при открытии диалога логина. Создаёт одноразовый QR-токен.
```go
func handleQrCreate(w http.ResponseWriter, r *http.Request) {
// 1. Сгенерировать криптографически безопасный токен
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
http.Error(w, "internal error", 500)
return
}
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
// 2. Определить бота по origin
botUsername := getBotForOrigin(r.Header.Get("Origin"))
// 3. Сохранить токен
authToken := AuthToken{
Token: token,
Status: "pending",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(5 * time.Minute),
}
saveAuthToken(authToken)
// 4. Ответить
qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
"url": qrURL,
})
}
func getBotForOrigin(origin string) string {
if strings.Contains(origin, "novo.market") {
return "novomarket_bot"
}
return "DexarSupport_bot"
}
```
**Ответ:**
```json
{ "token": "dG9rZW4tYWJj....", "url": "https://t.me/DexarSupport_bot?start=login_dG9rZW4tYWJj...." }
```
> Telegram ограничивает `start` до 64 символов. `login_` (6) + base64url из 32 байт (43) = 49 ✅
---
### 2. `GET /auth/qr/poll?token={token}`
Фронтенд вызывает каждые 3 секунды. Когда бот подтвердил — возвращает сессию и ставит cookie.
```go
func handleQrPoll(w http.ResponseWriter, r *http.Request) {
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
http.Error(w, "missing token", 400)
return
}
authToken, ok := getAuthToken(tokenStr)
if !ok {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "expired"})
return
}
switch authToken.Status {
case "pending":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "pending"})
case "confirmed":
session, err := getSession(authToken.SessionID)
if err != nil {
http.Error(w, "session not found", 500)
return
}
// Cookie в ДЕСКТОПНЫЙ браузер
domain := getDomainForOrigin(r.Header.Get("Origin"))
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: session.SessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteNoneMode,
MaxAge: 86400,
Domain: domain,
})
// Удалить использованный токен
deleteAuthToken(tokenStr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "confirmed",
"session": session,
})
}
}
func getDomainForOrigin(origin string) string {
if strings.Contains(origin, "novo.market") {
return ".novo.market"
}
return ".dexarmarket.ru"
}
```
**Ответы:**
| Статус | JSON |
|--------|------|
| Ждём | `{ "status": "pending" }` |
| Подтверждено | `{ "status": "confirmed", "session": { sessionId, telegramUserId, username, displayName, active, expiresAt } }` + `Set-Cookie` |
| Истекло | `{ "status": "expired" }` |
---
### 3. `POST /auth/qr/confirm` (внутренний, для бота)
Бот вызывает когда пользователь отсканировал QR. Привязывает сессию к токену.
```go
func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
// Проверить секрет бота
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
http.Error(w, "forbidden", 403)
return
}
var req struct {
Token string `json:"token"`
User struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"telegram_user"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", 400)
return
}
authToken, ok := getAuthToken(req.Token)
if !ok || authToken.Status != "pending" {
http.Error(w, "token not found or already used", 404)
return
}
// Создать сессию
displayName := req.User.FirstName
if req.User.LastName != "" {
displayName += " " + req.User.LastName
}
var username *string
if req.User.Username != "" {
username = &req.User.Username
}
session := Session{
SessionID: uuid.New().String(),
TelegramUserID: req.User.ID,
Username: username,
DisplayName: displayName,
Active: true,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
saveSession(session)
// Привязать сессию к токену
authToken.Status = "confirmed"
authToken.SessionID = session.SessionID
saveAuthToken(*authToken)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
```
**Запрос от бота:**
```json
{
"token": "dG9rZW4tYWJj...",
"telegram_user": {
"id": 123456789,
"first_name": "Иван",
"last_name": "Петров",
"username": "ivan_petrov"
}
}
```
---
### 4. `GET /auth/session`
Фронтенд вызывает для проверки текущей сессии. Читает cookie.
```go
func handleGetSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
session, err := getSession(cookie.Value)
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
if time.Now().After(session.ExpiresAt) {
session.Active = false
saveSession(session)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
```
**Формат ответа (200)** — фронтенд ожидает **точно эти поля**:
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"telegramUserId": 123456789,
"username": "ivan_petrov",
"displayName": "Иван Петров",
"active": true,
"expiresAt": "2026-03-25T14:30:00Z"
}
```
| Поле | Тип | Обязательно | Примечание |
|------|-----|-------------|------------|
| `sessionId` | string (UUID) | да | Используется для `/websession/{sessionId}` |
| `telegramUserId` | number | да | Telegram user ID |
| `username` | string / null | нет | Telegram @username |
| `displayName` | string | да | "Имя Фамилия" — показывается в UI |
| `active` | boolean | да | `false` = истекла |
| `expiresAt` | string (ISO 8601) | да | Фронтенд перепроверяет за 60 сек до |
**Ошибка:** любой HTTP не-200 → фронтенд считает "не авторизован".
---
### 5. `GET /auth/telegram/callback`
Для прямого входа (по кнопке в Telegram, не через QR). Открывается в браузере.
```go
func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", 400)
return
}
session, err := getSession(token)
if err != nil || !session.Active {
http.Error(w, "invalid or expired token", 401)
return
}
domain := getDomainForOrigin(r.Header.Get("Origin"))
if domain == "" {
domain = ".dexarmarket.ru" // fallback для прямого перехода
}
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: session.SessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteNoneMode,
MaxAge: 86400,
Domain: domain,
})
// Редирект на сайт
http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
}
```
---
### 6. `POST /auth/logout`
```go
func handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err == nil {
deleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteNoneMode,
MaxAge: -1,
Domain: ".dexarmarket.ru",
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"ok"}`))
}
```
---
## Cookie-параметры
| Параметр | Значение | Почему |
|----------|----------|--------|
| `Name` | `dx_session` | |
| `SameSite` | `None` | Фронтенд на `dexarmarket.ru`, API на `api.dexarmarket.ru:445` — разные origins |
| `Secure` | `true` | Обязательно при `SameSite=None` |
| `Domain` | `.dexarmarket.ru` | Доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` |
| `HttpOnly` | `true` | Недоступна из JS — защита от XSS |
| `MaxAge` | `86400` | 24 часа |
---
## CORS
Фронтенд шлёт `withCredentials: true`. Бэкенд обязан вернуть правильные заголовки.
```go
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
allowed := map[string]bool{
"https://dexarmarket.ru": true,
"https://www.dexarmarket.ru": true,
"https://novo.market": true,
"https://www.novo.market": true,
"http://localhost:4200": true,
}
if allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*"
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
next.ServeHTTP(w, r)
})
}
```
> **Критично:** `Access-Control-Allow-Origin` не может быть `"*"` при `withCredentials`. Вернуть конкретный origin.
---
## Роутинг
```go
mux := http.NewServeMux()
// Существующие
mux.HandleFunc("GET /items/{id}", handleGetItem)
mux.HandleFunc("GET /category", handleGetCategories)
mux.HandleFunc("POST /websession/{id}", handleWebSession)
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)
// Auth — прямой вход
mux.HandleFunc("GET /auth/session", handleGetSession)
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
mux.HandleFunc("POST /auth/logout", handleLogout)
// Auth — QR-логин
mux.HandleFunc("POST /auth/qr/create", handleQrCreate)
mux.HandleFunc("GET /auth/qr/poll", handleQrPoll)
mux.HandleFunc("POST /auth/qr/confirm", handleQrConfirm)
handler := corsMiddleware(mux)
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)
```
---
## Telegram-бот
### Обработчик `/start`
```go
const (
confirmURL = "http://localhost:8080/auth/qr/confirm"
botInternalSecret = os.Getenv("BOT_INTERNAL_SECRET")
)
func handleStart(update tgbotapi.Update) {
text := update.Message.Text
user := update.Message.From
switch {
case strings.HasPrefix(text, "/start login_"):
handleQrLogin(update, user, strings.TrimPrefix(text, "/start login_"))
case strings.HasPrefix(text, "/start auth"):
handleDirectAuth(update, user)
default:
sendWelcome(update)
}
}
```
### QR-логин (основной)
```go
func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
reqBody := map[string]interface{}{
"token": token,
"telegram_user": map[string]interface{}{
"id": user.ID,
"first_name": user.FirstName,
"last_name": user.LastName,
"username": user.UserName,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", confirmURL, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Bot-Secret", botInternalSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
"❌ Не удалось войти. QR-код мог устареть. Попробуйте обновить страницу и отсканировать новый QR.")
bot.Send(msg)
return
}
defer resp.Body.Close()
displayName := buildDisplayName(user)
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
fmt.Sprintf("✅ Вы вошли на сайт как %s!\n\nМожете вернуться в браузер — страница обновится автоматически.", displayName))
bot.Send(msg)
}
func buildDisplayName(user *tgbotapi.User) string {
name := user.FirstName
if user.LastName != "" {
name += " " + user.LastName
}
return name
}
```
### Прямой вход (кнопка, для обратной совместимости)
```go
func handleDirectAuth(update tgbotapi.Update, user *tgbotapi.User) {
session := Session{
SessionID: uuid.New().String(),
TelegramUserID: user.ID,
Username: stringPtr(user.UserName),
DisplayName: buildDisplayName(user),
Active: true,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
saveSession(session)
callbackURL := "https://api.dexarmarket.ru:445/auth/telegram/callback"
loginURL := callbackURL + "?token=" + session.SessionID
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
),
)
bot.Send(msg)
}
```
### Запуск бота (long polling)
```go
func main() {
bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := bot.GetUpdatesChan(u)
for update := range updates {
if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
handleStart(update)
}
}
}
```
---
## Синхронизация корзины
Сразу после QR-логина фронтенд автоматически отправляет корзину:
```
POST /websession/{sessionId}
```
Тело — массив:
```json
[
{
"itemID": 123,
"quantity": 2,
"colour": "#ff0000",
"size": "XL",
"price": 1500
}
]
```
| Поле | Тип | Примечание |
|------|-----|------------|
| `itemID` | number | ID товара |
| `quantity` | number | Количество |
| `colour` | string | CSS hex (`#ff0000`). Бэкенд отдаёт `0xff0000`, фронтенд конвертирует |
| `size` | string | `"default"` если размер один |
| `price` | number | Финальная цена **с учётом скидки** |
> Этот эндпоинт (`POST /websession/{id}`) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина.
---
## Безопасность
### Криптографический токен
```go
tokenBytes := make([]byte, 32) // 256 бит
crypto/rand.Read(tokenBytes)
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
```
**НЕ использовать:** `math/rand`, UUID, timestamp.
### Токен одноразовый
- После `confirmed` → удалить при первом успешном `poll`
- После 5 минут → автоудаление (TTL)
- Повторный `poll``"expired"`
### Защита `/auth/qr/confirm`
```go
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
http.Error(w, "forbidden", 403)
return
}
```
Дополнительно: можно ограничить по IP (`127.0.0.1`) если бот на том же сервере.
### Rate limiting для `/auth/qr/create`
Не более **5 токенов в минуту** с одного IP:
```go
var ipCounts sync.Map
func rateLimitQrCreate(ip string) bool {
key := ip + ":" + time.Now().Format("2006-01-02T15:04")
val, _ := ipCounts.LoadOrStore(key, new(int32))
count := atomic.AddInt32(val.(*int32), 1)
return count <= 5
}
```
---
## Переменные окружения
```env
BOT_TOKEN=123456:ABC-DEF...
BOT_INTERNAL_SECRET=случайная-строка-минимум-32-символа
FRONTEND_URL=https://dexarmarket.ru
SESSION_TTL=24h
REDIS_URL=localhost:6379
```
`BOT_INTERNAL_SECRET` должен совпадать в env сервера и env бота.
---
## Тестирование
### curl-тесты
**1. Создание токена:**
```bash
curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \
-H "Origin: https://dexarmarket.ru"
# → { "token": "dG9r...", "url": "https://t.me/DexarSupport_bot?start=login_dG9r..." }
```
**2. Поллинг (до подтверждения):**
```bash
curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "pending" }
```
**3. Подтверждение (имитация бота):**
```bash
curl -X POST https://api.dexarmarket.ru:445/auth/qr/confirm \
-H "Content-Type: application/json" \
-H "X-Bot-Secret: ваш-секрет" \
-d '{"token":"dG9r...","telegram_user":{"id":123,"first_name":"Тест","last_name":"","username":"testuser"}}'
# → { "status": "ok" }
```
**4. Поллинг (после подтверждения):**
```bash
curl -v "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
# → { "status": "confirmed", "session": {...} } + Set-Cookie: dx_session=...
```
**5. E2E:**
1. Открыть маркетплейс → добавить товар в корзину
2. Нажать "Оформить заказ" → появляется диалог с QR
3. Отсканировать QR телефоном → Telegram → бот: "✅ Вы вошли!"
4. Через 3 сек диалог закрывается → авторизован
5. Корзина синхронизирована (`POST /websession/{sessionId}`)
### Отладка
| Проблема | Где смотреть |
|----------|-------------|
| QR не показывается | `POST /auth/qr/create` — ошибка? CORS? |
| QR отсканирован, ничего не происходит | Бот получил `/start login_...`? Бот вызвал `confirm`? |
| Бот пишет "❌ QR устарел" | Токен expired? 5 минут прошло? |
| Поллинг "pending" бесконечно | Бот не вызвал `confirm`. Логи бота |
| Поллинг "confirmed" но cookie нет | `SameSite`, `Secure`, `Domain`, CORS |
---
## Чеклист
### Бэкенд (Go)
- [ ] Структура `Session` + `AuthToken`, функции save/get/delete
- [ ] `POST /auth/qr/create` — генерация токена
- [ ] `GET /auth/qr/poll?token=...` — статус + cookie при confirmed
- [ ] `POST /auth/qr/confirm` — приём от бота с `X-Bot-Secret`
- [ ] `GET /auth/session` — чтение cookie, JSON сессии
- [ ] `GET /auth/telegram/callback?token=...` — cookie + редирект
- [ ] `POST /auth/logout` — удаление сессии и cookie
- [ ] TTL 5 мин для токенов, 24ч для сессий
- [ ] Rate limiting `/auth/qr/create` (5/мин/IP)
- [ ] Очистка устаревших токенов
- [ ] CORS middleware
- [ ] `BOT_INTERNAL_SECRET` в env
### Telegram бот
- [ ] Обработка `/start login_{token}``POST /auth/qr/confirm`
- [ ] Обработка `/start auth` → создание сессии + кнопка "Войти"
- [ ] Сообщения: "✅ Вы вошли" / "❌ QR устарел"
- [ ] `BOT_INTERNAL_SECRET` в env (совпадает с сервером)
- [ ] `BOT_TOKEN` в env

View File

@@ -1,489 +0,0 @@
# Telegram-бот авторизация — Руководство по реализации
> **Цель документа**: пошаговая инструкция для Go-разработчика. После выполнения всех шагов — авторизация через Telegram-бота будет работать.
---
## Статус
| Компонент | Готов? |
|-----------|--------|
| Frontend (Angular) — диалог входа, поллинг, сессия, корзина | ✅ Готов |
| Telegram бот (обработка `/start`) | ❌ Нужно сделать |
| Backend — 3 HTTP-эндпоинта авторизации | ❌ Нужно сделать |
| Хранилище сессий (Redis / PostgreSQL / in-memory) | ❌ Нужно сделать |
| CORS для cookie-based запросов | ❌ Нужно настроить |
---
## Архитектура
```
┌──────────┐ 1. t.me/Bot?start=auth_token ┌──────────────┐
│ │ ──────────────────────────────────────>│ Telegram │
│ Браузер │ │ (облако) │
│ │ 2. /start auth_{token} │ │
│ │ └──────┬───────┘
│ │ │
│ │ v
│ │ ┌──────────────┐
│ │ 4. GET /auth/session │ Go Backend │
│ │ (Cookie) — поллинг 3 сек │ │
│ │ ──────────────────────────────────────>│ - Bot handler│
│ │ │ - Auth API │
│ │ 5. { sessionId, displayName, ... } │ - Sessions │
│ │ <─────────────────────────────────────│ │
│ │ └──────┬───────┘
│ │ 3. GET /auth/telegram/callback │
│ │ + Set-Cookie: session=... ◄──────┘
│ │ <─────────────────────────────────────
└──────────┘
```
---
## Шаг 1: Создать бота в @BotFather
1. Открыть https://t.me/BotFather
2. `/newbot` (или использовать существующего)
3. Сохранить **BOT_TOKEN** — он понадобится в шаге 3
Нужны два бота:
| Бренд | Username бота | Домен фронтенда | API сервер |
|-------|---------------|------------------|------------|
| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` |
| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` |
---
## Шаг 2: Хранилище сессий
Нужна таблица или Map для хранения сессий:
```go
type Session struct {
SessionID string `json:"sessionId"`
TelegramUserID int64 `json:"telegramUserId"`
Username *string `json:"username"` // может быть null
DisplayName string `json:"displayName"`
Active bool `json:"active"`
ExpiresAt time.Time `json:"expiresAt"`
}
```
Можно использовать:
- **Redis** (рекомендуется для продакшена) — `SET session:{id} {json} EX 86400`
- **PostgreSQL** — таблица `auth_sessions`
- **sync.Map** — для MVP/тестирования (не переживёт рестарт)
**Время жизни сессии:** 24 часа (фронтенд перепроверяет за 60 сек до `expiresAt`).
---
## Шаг 3: Обработчик команды `/start` в боте
### Что получает бот
Когда пользователь кликает ссылку:
```
https://t.me/DexarSupport_bot?start=auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback
```
Бот получает Update с `Message.Text`:
```
/start auth_https%3A%2F%2Fapi.dexarmarket.ru%3A445%2Fauth%2Ftelegram%2Fcallback
```
### Что должен сделать бот
```go
func handleStart(update tgbotapi.Update) {
text := update.Message.Text
user := update.Message.From
// 1. Извлечь callback URL
if !strings.HasPrefix(text, "/start auth_") {
// Обычный /start — показать приветствие
return
}
callbackURL, _ := url.QueryUnescape(strings.TrimPrefix(text, "/start auth_"))
// 2. Создать сессию
session := Session{
SessionID: uuid.New().String(),
TelegramUserID: user.ID,
Username: stringPtr(user.UserName), // может быть ""
DisplayName: buildDisplayName(user), // "Имя Фамилия"
Active: true,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
saveSession(session) // сохранить в Redis/DB
// 3. Отправить пользователю кнопку "Войти"
// Кнопка открывает callback URL с токеном сессии
loginURL := callbackURL + "?token=" + session.SessionID
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
),
)
bot.Send(msg)
}
func buildDisplayName(user *tgbotapi.User) string {
name := user.FirstName
if user.LastName != "" {
name += " " + user.LastName
}
return name
}
```
> **Важно**: Telegram ограничивает параметр `start` до 64 символов. URL callback'а ~62 символа — проходит впритык. Альтернатива — не передавать callback URL в параметре, а захардкодить его в боте (рекомендуется).
### Рекомендация: захардкодить callback URL
```go
const callbackURL = "https://api.dexarmarket.ru:445/auth/telegram/callback"
func handleStart(update tgbotapi.Update) {
text := update.Message.Text
if !strings.HasPrefix(text, "/start auth") {
return
}
// ... создать сессию ...
loginURL := callbackURL + "?token=" + session.SessionID
// ... отправить кнопку ...
}
```
Тогда фронтенд может упростить ссылку до:
```
https://t.me/DexarSupport_bot?start=auth
```
---
## Шаг 4: HTTP-эндпоинт `GET /auth/telegram/callback`
Этот URL открывается в **браузере пользователя** (по клику на кнопку в боте).
```go
// GET /auth/telegram/callback?token={sessionId}
func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", 400)
return
}
// 1. Проверить что сессия существует
session, err := getSession(token)
if err != nil || !session.Active {
http.Error(w, "invalid or expired token", 401)
return
}
// 2. Установить HttpOnly cookie
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: session.SessionID,
Path: "/",
HttpOnly: true,
Secure: true, // обязательно для HTTPS
SameSite: http.SameSiteNoneMode, // кросс-доменные запросы
MaxAge: 86400, // 24 часа
Domain: ".dexarmarket.ru", // доступна для поддоменов
})
// 3. Редирект на сайт
http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
}
```
### Про SameSite и Domain
| Параметр | Значение | Почему |
|----------|----------|--------|
| `SameSite` | `None` | Frontend на `dexarmarket.ru` шлёт запросы к `api.dexarmarket.ru:445` — разные origins |
| `Secure` | `true` | Обязательно при `SameSite=None` |
| `Domain` | `.dexarmarket.ru` | Cookie доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` |
| `HttpOnly` | `true` | Недоступна из JavaScript — защита от XSS |
---
## Шаг 5: HTTP-эндпоинт `GET /auth/session`
Фронтенд вызывает каждые 3 секунды с `withCredentials: true` (браузер подставляет cookie).
```go
// GET /auth/session
func handleGetSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
session, err := getSession(cookie.Value)
if err != nil {
http.Error(w, "unauthorized", 401)
return
}
// Проверить не истекла ли
if time.Now().After(session.ExpiresAt) {
session.Active = false
saveSession(session) // обновить в хранилище
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
```
### Формат ответа (200)
Фронтенд ожидает **точно эти поля**:
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"telegramUserId": 123456789,
"username": "ivan_petrov",
"displayName": "Иван Петров",
"active": true,
"expiresAt": "2026-03-25T14:30:00Z"
}
```
| Поле | Тип | Обязательно | Примечание |
|------|-----|-------------|------------|
| `sessionId` | string (UUID) | да | Используется для websession эндпоинтов покупки |
| `telegramUserId` | number | да | Telegram user ID |
| `username` | string / null | нет | Telegram @username (может отсутствовать) |
| `displayName` | string | да | "Имя Фамилия" — показывается в UI |
| `active` | boolean | да | `false` = сессия истекла |
| `expiresAt` | string (ISO 8601) | да | Время истечения. Фронтенд перепроверяет за 60 сек до |
### Ответ при ошибке (401)
Любой HTTP статус не-200 → фронтенд считает "не авторизован".
---
## Шаг 6: HTTP-эндпоинт `POST /auth/logout`
```go
// POST /auth/logout
func handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("dx_session")
if err == nil {
deleteSession(cookie.Value)
}
// Очистить cookie
http.SetCookie(w, &http.Cookie{
Name: "dx_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteNoneMode,
MaxAge: -1, // удалить
Domain: ".dexarmarket.ru",
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"ok"}`))
}
```
---
## Шаг 7: CORS
Фронтенд шлёт запросы с `withCredentials: true` (cookie). Бэкенд **обязан** вернуть правильные CORS-заголовки.
```go
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Только для наших доменов
allowed := map[string]bool{
"https://dexarmarket.ru": true,
"https://www.dexarmarket.ru": true,
"https://novo.market": true,
"https://www.novo.market": true,
"http://localhost:4200": true, // для разработки
}
if allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*" — запрещено с credentials
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
next.ServeHTTP(w, r)
})
}
```
> **Критично**: `Access-Control-Allow-Origin` **не может быть** `"*"` если используется `withCredentials`. Нужно вернуть конкретный origin.
---
## Шаг 8: Роутинг — добавить к существующему API
Эти 3 эндпоинта добавляются к существующему Go серверу на `api.dexarmarket.ru:445`:
```go
mux := http.NewServeMux()
// Существующие эндпоинты
mux.HandleFunc("GET /items/{id}", handleGetItem)
mux.HandleFunc("GET /category", handleGetCategories)
mux.HandleFunc("POST /websession/{id}", handleWebSession)
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)
// НОВЫЕ — авторизация
mux.HandleFunc("GET /auth/session", handleGetSession)
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
mux.HandleFunc("POST /auth/logout", handleLogout)
// Обернуть в CORS middleware
handler := corsMiddleware(mux)
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)
```
---
## Шаг 9: Запуск Telegram бота
Бот может работать в режиме **long polling** (проще) или **webhook** (production).
### Long Polling (для начала)
```go
func main() {
bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := bot.GetUpdatesChan(u)
for update := range updates {
if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
handleStart(update)
}
}
}
```
### Переменные окружения
```env
BOT_TOKEN=123456:ABC-DEF... # от @BotFather
CALLBACK_URL=https://api.dexarmarket.ru:445/auth/telegram/callback
FRONTEND_URL=https://dexarmarket.ru
SESSION_TTL=24h
REDIS_URL=localhost:6379 # если используете Redis
```
---
## Шаг 10: Проверка — как тестировать
### 1. Запустить бот и сервер
```bash
BOT_TOKEN=... go run .
```
### 2. Открыть маркетплейс, нажать "Войти"
Фронтенд откроет `https://t.me/DexarSupport_bot?start=auth_...`
### 3. В Telegram — нажать "Start" → кнопку "Войти на сайт"
Бот создаёт сессию, кнопка ведёт на `/auth/telegram/callback?token=...`
### 4. Браузер получает cookie, редиректит на маркетплейс
### 5. Фронтенд (поллинг каждые 3 сек) находит сессию → диалог закрывается
### Логи для отладки
Что проверять если не работает:
| Проблема | Где смотреть |
|----------|-------------|
| Бот не отвечает | `BOT_TOKEN` правильный? Бот получает updates? |
| Кнопка "Войти" не открывается | URL `/auth/telegram/callback` доступен? SSL? |
| Cookie не устанавливается | `SameSite`, `Secure`, `Domain` правильные? HTTPS? |
| Фронтенд не находит сессию | CORS настроен? `Access-Control-Allow-Credentials: true`? |
| Сессия не находится | Хранилище работает? TTL не слишком маленький? |
---
## Полный чеклист
- [ ] Бот создан в @BotFather, токен получен
- [ ] `BOT_TOKEN` сохранён в переменных окружения сервера
- [ ] Хранилище сессий настроено (Redis / PostgreSQL / sync.Map)
- [ ] Обработчик `/start auth_...` в боте создаёт сессию и отправляет inline-кнопку
- [ ] `GET /auth/telegram/callback?token=...` устанавливает HttpOnly cookie и редиректит
- [ ] `GET /auth/session` читает cookie и возвращает JSON сессии (или 401)
- [ ] `POST /auth/logout` удаляет сессию и cookie
- [ ] CORS настроен для `dexarmarket.ru` / `novo.market` с `credentials: true`
- [ ] SSL сертификат установлен (обязательно для `Secure` cookie)
- [ ] Тест: клик "Войти" → Telegram → кнопка → cookie → сессия найдена ✅
---
## Как фронтенд использует `sessionId` после авторизации
После успешного входа `sessionId` из ответа `/auth/session` используется для **покупки**:
```
POST /websession/{sessionId} ← синхронизация корзины
POST /websession/{sessionId}/qr ← создание QR для СБП
GET /websession/{sessionId}/{qrId} ← проверка статуса оплаты
```
Формат данных корзины:
```json
[
{
"itemID": 123,
"quantity": 2,
"colour": "#ff0000",
"size": "XL",
"price": 1500
}
]
```
> Цвет приходит от бэкенда как `0xff0000`, фронтенд конвертирует в `#ff0000`.
> Размер `"default"` — не показывается пользователю.
---
## Дополнительно: Telegram CloudStorage
Внутри Telegram Mini App корзина дополнительно сохраняется в `CloudStorage` (API Telegram). Это **автоматически** — бэкенду ничего делать не нужно. Работает только когда сайт открыт через Telegram.
## Дополнительно: `initDataUnsafe`
Фронтенд читает `window.Telegram.WebApp.initDataUnsafe.user` для отображения имени пользователя в UI (при написании отзывов). Это **не используется для авторизации** — только для отображения. Бэкенду ничего делать не нужно.

View File

@@ -31,13 +31,41 @@
<div class="qr-section">
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
<div class="qr-container">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
alt="QR Code"
width="180"
height="180"
loading="lazy" />
</div>
@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') {
<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>

View File

@@ -122,6 +122,42 @@ h2 {
display: block;
border-radius: 4px;
}
&.qr-loading {
align-items: center;
justify-content: center;
width: 204px;
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 {
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;
}
}
}
}

View File

@@ -1,6 +1,8 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { CartService } from '../../services/cart.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { getDiscountedPrice } from '../../utils/item.utils';
@Component({
selector: 'app-telegram-login',
@@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
styleUrls: ['./telegram-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelegramLoginComponent implements OnInit, OnDestroy {
export class TelegramLoginComponent implements OnDestroy {
private authService = inject(AuthService);
private cartService = inject(CartService);
showDialog = this.authService.showLoginDialog;
status = this.authService.status;
loginUrl = signal('');
qrToken = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
private pollTimer?: ReturnType<typeof setInterval>;
ngOnInit(): void {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
constructor() {
effect(() => {
if (this.showDialog()) {
this.initQrLogin();
} else {
this.stopPolling();
}
});
}
ngOnDestroy(): void {
@@ -31,32 +44,87 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
this.stopPolling();
}
/** Open Telegram login link and start polling for session */
openTelegramLogin(): void {
window.open(this.loginUrl(), '_blank');
this.startPolling();
if (!this.pollTimer) {
this.startPolling(this.qrToken());
}
}
/** Start polling the backend to detect when user completes Telegram auth */
private startPolling(): void {
refreshQr(): void {
this.stopPolling();
// Check every 3 seconds for up to 5 minutes
this.initQrLogin();
}
private 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: () => {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
this.qrStatus.set('error');
}
});
}
private startPolling(token: string): void {
this.stopPolling();
if (!token) return;
let checks = 0;
this.pollTimer = setInterval(() => {
checks++;
if (checks > 100) { // 100 * 3s = 5 min
if (checks > 100) {
this.stopPolling();
this.qrStatus.set('expired');
return;
}
this.authService.checkSession();
// If authenticated, stop polling and close dialog
if (this.authService.isAuthenticated()) {
this.stopPolling();
this.authService.hideLogin();
}
this.authService.pollQrToken(token).subscribe({
next: (res) => {
switch (res.status) {
case 'confirmed':
this.stopPolling();
if (res.session) {
this.syncCartAndComplete(res.session.sessionId);
} else {
this.authService.onTelegramLoginComplete();
}
break;
case 'expired':
this.stopPolling();
this.qrStatus.set('expired');
break;
}
},
error: () => {
// Network error — keep polling
}
});
}, 3000);
}
private syncCartAndComplete(sessionId: string): void {
const cartItems = this.cartService.items().map(item => ({
itemID: item.itemID,
quantity: item.quantity,
colour: item.colour || '',
size: item.size || '',
price: item.discount > 0
? item.price * (1 - item.discount / 100)
: item.price,
}));
this.authService.syncCart(sessionId, cartItems).subscribe(() => {
this.authService.onTelegramLoginComplete();
});
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);

View File

@@ -205,5 +205,6 @@ export const en: Translations = {
loginWithTelegram: 'Log in with Telegram',
orScanQr: 'Or scan the QR code',
loginNote: 'You will be redirected back after login',
qrExpired: 'QR code expired. Click to refresh',
},
};

View File

@@ -1,4 +1,4 @@
import { Translations } from './translations';
import { Translations } from './translations';
export const hy: Translations = {
header: {
@@ -205,5 +205,6 @@ export const hy: Translations = {
loginWithTelegram: 'Մուտք Telegram-ով',
orScanQr: 'Կամ սքանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
},
};

View File

@@ -205,5 +205,6 @@ export const ru: Translations = {
loginWithTelegram: 'Войти через Telegram',
orScanQr: 'Или отсканируйте QR-код',
loginNote: 'После входа вы будете перенаправлены обратно',
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
},
};

View File

@@ -203,5 +203,6 @@ export interface Translations {
loginWithTelegram: string;
orScanQr: string;
loginNote: string;
qrExpired: string;
};
}

View File

@@ -17,4 +17,9 @@ export interface TelegramAuthData {
hash: string;
}
export interface QrPollResponse {
status: 'pending' | 'confirmed' | 'expired';
session?: AuthSession;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -1,7 +1,7 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, map, tap } from 'rxjs';
import { AuthSession, AuthStatus } from '../models/auth.model';
import { AuthSession, AuthStatus, QrPollResponse } from '../models/auth.model';
import { environment } from '../../environments/environment';
@Injectable({
@@ -82,6 +82,34 @@ export class AuthService {
return this.getTelegramLoginUrl();
}
/** Create a one-time QR login token via backend */
createQrToken(): Observable<{ token: string; url: string }> {
return this.http.post<{ token: string; url: string }>(
`${this.apiUrl}/auth/qr/create`,
{},
{ withCredentials: true }
);
}
/** Poll the QR token status (pending → confirmed / expired) */
pollQrToken(token: string): Observable<QrPollResponse> {
return this.http.get<QrPollResponse>(
`${this.apiUrl}/auth/qr/poll`,
{
params: { token },
withCredentials: true,
}
);
}
/** Sync local cart to the backend session after login */
syncCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<unknown> {
if (!items.length) return of(null);
return this.http.post(`${this.apiUrl}/websession/${sessionId}`, items, {
withCredentials: true,
}).pipe(catchError(() => of(null)));
}
/** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void {
this.showLoginSignal.set(true);