Auth, API и сквозное тестирование

Перед началом: Модули 1–4 (serverless-дизайн, Clean Architecture на Go). Инструменты: JWT (golang-jwt/jwt/v5), bcrypt, Yandex API Gateway, curl. Время: ~6 ч.

🔥 Инцидент: JWT в логах — чужие сообщения на экране

Пользователь логинится через OAuth → получает JWT access token. Всё работает: токен в Authorization заголовке, API его принимает, сообщения доставляются. Команда выкатывает в production.

Через неделю — инцидент. Разработчик добавил request-логирование «для дебага»: весь HTTP-запрос, включая заголовки, пишется в логи. Вместе с Authorization: Bearer eyJ.... Логи собираются в централизованную систему, доступную... всей компании. Злоумышленник читает логи, извлекает чужой JWT — и читает чужие сообщения. За час — 50 аккаунтов скомпрометированы.

Нужна сквозная авторизация: Edge → API Gateway → Handler → Repository, с проверкой на каждом слое. JWT не должен утекать в логи, токены должны быть короткоживущими, refresh — ротироваться. В этом модуле — как это построить.

Из M4: мы заложили Clean Architecture (UseCase → Service → Repository, DI). Теперь реализуем первый продакшен-сценарий — авторизацию, настроим API Gateway и проведём сквозной тест чата двумя пользователями через curl.

📖 Теория

В этом модуле реализуем авторизацию (регистрация, вход, JWT), настраиваем API Gateway (маршруты, JWT validation, CORS) и проводим сквозной тест чата двумя пользователями через curl.

Регистрация

go
// internal/application/usecase/register.go
func (uc *RegisterUseCase) Execute(ctx context.Context, email, password, name string) (*domain.User, error) {
    if !isValidEmail(email) {
        return nil, domain.ErrInvalidEmail
    }
    if len(password) < 8 {
        return nil, domain.ErrWeakPassword
    }

    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("bcrypt: %w", err)
    }

    user := &domain.User{
        ID:           uuid.NewString(),
        Email:        email,
        Name:         name,
        PasswordHash: string(hash),
    }
    if err := uc.repo.Create(ctx, user); err != nil {
        return nil, err
    }
    return user, nil
}
POST /auth/register
{"email":"alice@example.com","password":"secret123","name":"Alice"}

→ 201 Created
{"id":"u-1","email":"alice@example.com","name":"Alice"}

Вход и выдача JWT

go
// internal/application/usecase/login.go
func (uc *LoginUseCase) Execute(ctx context.Context, email, password string) (*TokenPair, error) {
    user, err := uc.repo.GetByEmail(ctx, email)
    if err != nil {
        return nil, domain.ErrInvalidCredentials
    }
    if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
        return nil, domain.ErrInvalidCredentials
    }
    return uc.generateTokenPair(user.ID)
}
go
func (uc *LoginUseCase) generateTokenPair(userID string) (*TokenPair, error) {
    access, err := uc.jwt.Sign(jwt.Claims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
        },
    })
    refresh, err := uc.jwt.Sign(jwt.Claims{
        UserID: userID,
        Type:   "refresh",
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
        },
    })
    return &TokenPair{AccessToken: access, RefreshToken: refresh}, nil
}
POST /auth/login
{"email":"alice@example.com","password":"secret123"}

→ 200 OK
{"access_token":"eyJ...","refresh_token":"eyJ...","user":{"id":"u-1","name":"Alice"}}

Refresh (ротация)

POST /auth/refresh
{"refresh_token":"eyJ..."}

→ 200 OK — новая пара токенов, старый refresh инвалидируется

Auth middleware

Каждая защищённая функция извлекает пользователя из JWT (проверенного API Gateway):

go
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := r.Header.Get("X-User-ID") // API Gateway уже валидировал JWT
        if userID == "" {
            http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), ctxKeyUserID, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

API Gateway: маршруты, JWT, CORS

API Gateway конфигурируется декларативно (OpenAPI-спецификация):

yaml
# API Gateway spec (фрагмент)
openapi: 3.0.0
paths:
  /auth/register:
    post:
      x-yc-apigateway-integration:
        type: cloud_functions
        function_id: ${AUTH_FUNCTION_ID}
  /rooms:
    get:
      x-yc-apigateway-integration:
        type: cloud_functions
        function_id: ${CHAT_FUNCTION_ID}
      security:
        - jwt: []           # JWT validation на уровне шлюза
  /rooms/{id}/messages:
    post:
      x-yc-apigateway-integration:
        type: cloud_functions
        function_id: ${CHAT_FUNCTION_ID}
      security:
        - jwt: []
  /ws:
    get:
      x-yc-apigateway-integration:
        type: serverless_containers
        container_id: ${WS_CONTAINER_ID}
# CORS
x-yc-apigateway-cors:
  origin: "https://chat.example.com"
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
  headers: ["Authorization", "Content-Type", "X-Trace-ID"]

API Gateway проверяет JWT и передаёт X-User-ID в функцию. Функция не делает повторную проверку подписи — она доверяет шлюзу, но проверяет наличие заголовка (defense in depth).

Полный список эндпоинтов

МетодПутьAuthОписание
POST/auth/register—Регистрация (email + password + name)
POST/auth/login—Вход → {access_token, refresh_token}
POST/auth/refreshrefreshРотация токенов
POST/roomsJWTСоздать комнату (name, type, participants)
GET/roomsJWTСписок моих комнат
POST/rooms/:id/participantsJWTДобавить участника
DELETE/rooms/:id/participants/:uidJWTИсключить участника
POST/rooms/:id/messagesJWTОтправить сообщение (text, request_id) → 202
GET/rooms/:id/messagesJWTИстория (?cursor=&limit=20)
WS/wsJWT (query)WebSocket: real-time сообщения

Создание комнаты

POST /rooms
Authorization: Bearer eyJ...
{"name":"team-chat","type":"group","participant_ids":["u-2"]}

→ 201 Created
{"id":"r-1","name":"team-chat","type":"group","created_by":"u-1"}

Отправка сообщения

POST /rooms/r-1/messages
Authorization: Bearer eyJ...
{"text":"Привет, Bob!","request_id":"req-001"}

→ 202 Accepted
{"id":"m-1","status":"accepted"}

История сообщений

GET /rooms/r-1/messages?limit=20
Authorization: Bearer eyJ...

→ 200 OK
{"messages":[{"id":"m-1","sender_id":"u-1","text":"Привет, Bob!","created_at":"..."}],"next_cursor":null}

WebSocket: события

Подключение: WS /ws?token=eyJ... (JWT в query-параметре при handshake).

СобытиеНаправлениеДанные
messageserver→clientновое сообщение в комнате
presenceserver→clientпользователь online/offline
typingclient→server{room_id, is_typing}
errorserver→clientошибка доставки

Локальная разработка

Для тестов через curl (ниже) нужен локальный сервер. В serverless мы не запускаем «Cloud Function» на своей машине — вместо этого собираем тот же Go-код в обычный HTTP-сервер и подключаем адаптеры:

docker-compose.yml (локальные эмуляторы)

yaml
services:
  ydb-local:
    image: cr.yandex/yc/yandex-docker-local-ydb:latest
    ports: ["2136:2136", "@local"]
    environment:
      YDB_USE_IN_MEMORY: "true"

  api:
    build: .
    ports: ["8080:8080"]
    environment:
      YDB_ENDPOINT: "grpc://ydb-local:2136"
      YDB_DATABASE: "/local"
      PORT: "8080"
      JWT_SECRET: "local-dev-secret"
    depends_on: ["ydb-local"]

.env для локального запуска

bash
# Запуск без Docker (нужен только локальный YDB)
YDB_ENDPOINT=grpc://localhost:2136
YDB_DATABASE=/local
PORT=8080
JWT_SECRET=local-dev-secret
bash
go run ./cmd/server      # HTTP-сервер на :8080
# или
docker-compose up        # поднимает YDB + API

Composition Root определяет окружение по переменным: если есть YDB_ENDPOINT — подключается к YDB (локальному или облачному); если нет — использует in-memory адаптеры (для быстрых unit-тестов).

Миграции (создание таблиц)

Перед первым запуском примените схему (из модуля 6):

bash
go run ./cmd/migrate up
# Создаёт: users, rooms, room_participants, messages

API-документация (Swagger/OpenAPI)

API Gateway-спецификация — это и есть OpenAPI-документ. Для интерактивной документации:

  • Загрузите openapi.yaml в Swagger UI или Redoc — получите интерактивную страницу со всеми эндпоинттами, схемами и примерами.
  • Для автогенерации из Go-кода: аннотации swaggo/swag → swag init → docs/swagger.json.
bash
# Генерация Swagger из аннотаций Go
go install github.com/swaggo/swag/cmd/swag@latest
swag init -g cmd/server/main.go -o docs
# Результат: docs/swagger.json + docs/docs.go → подключить к Swagger UI-эндпоинту

Компромиссы (Trade-offs)

РешениеПлюсМинус
Access 15 мин + Refresh 7 днейбаланс безопасности и UXсложнее инвалидировать все сессии
JWT validation на API Gatewayодин контракт, нет дублей в кодедоверие шлюзу (defense in depth)
Refresh rotationкомпрометация ограничена по времениинвалидация старых при сбое сети
bcrypt DefaultCostдостаточно для паролей, медленнее брутфорсамедленнее argon2 на GPU-атаках
X-User-ID из шлюзафункция не проверяет подпись повторнонужен trust между шлюзом и функцией
WebSocket: JWT в queryработает при handshakeтокен в URL → может попасть в access-логи

Вывод модуля: безопасность — это слои. JWT короткоживущий, проверка на Edge, заголовки не пишутся в логи, refresh ротируется. Один дырявый слой ломает всю цепочку.

Сквозной тест: Alice и Bob

Предварительно: запустите локальный сервер (см. «Локальная разработка» выше) и примените миграции.

1. Регистрация

bash
# Alice
curl -s -X POST http://localhost:8080/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secret123","name":"Alice"}'

# Bob
curl -s -X POST http://localhost:8080/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"secret456","name":"Bob"}'

2. Вход

bash
# Alice
ALICE_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secret123"}' | jq -r .access_token)

# Bob
BOB_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"secret456"}' | jq -r .access_token)

3. Alice создаёт комнату и приглашает Bob

bash
# Создать P2P-комнату
ROOM_ID=$(curl -s -X POST http://localhost:8080/rooms \
  -H "Authorization: Bearer $ALICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"alice-bob","type":"p2p","participant_ids":["u-2"]}' | jq -r .id)

echo "Room: $ROOM_ID"

4. Alice отправляет сообщение

bash
curl -s -X POST "http://localhost:8080/rooms/$ROOM_ID/messages" \
  -H "Authorization: Bearer $ALICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"Привет, Bob!","request_id":"req-001"}'

5. Bob читает историю

bash
curl -s "http://localhost:8080/rooms/$ROOM_ID/messages?limit=20" \
  -H "Authorization: Bearer $BOB_TOKEN" | jq .

6. Bob отвечает

bash
curl -s -X POST "http://localhost:8080/rooms/$ROOM_ID/messages" \
  -H "Authorization: Bearer $BOB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"Привет, Alice!","request_id":"req-002"}'

7. Alice видит ответ

bash
curl -s "http://localhost:8080/rooms/$ROOM_ID/messages?limit=20" \
  -H "Authorization: Bearer $ALICE_TOKEN" | jq .

8. Real-time (WebSocket)

bash
# Bob подключается к WebSocket и получает новые сообщения
# (используйте websocat или wscat)
websocat "ws://localhost:8080/ws?token=$BOB_TOKEN"

# Alice отправляет → Bob мгновенно видит в терминале websocat:
# {"type":"message","room_id":"r-1","sender_id":"u-1","text":"Bob, ты тут?"}

Чеклист production-ready MVP

  • Регистрация (bcrypt, валидация email/пароля)
  • Вход (генерация JWT пары)
  • Refresh (ротация, инвалидация старого)
  • Auth middleware (проверка заголовка от API Gateway)
  • API Gateway (маршруты, JWT validation, CORS)
  • Создание комнаты (CRUD, участники)
  • Отправка сообщения (202 + YMQ)
  • История (пагинация по курсору)
  • WebSocket (real-time доставка)
  • Идемпотентность (request_id)
  • Сквозной тест двумя пользователями (curl)

🧩 Проверь себя

1. Refresh token rotation — зачем?
2. Зачем middleware при authentication/authorization (Edge→logic)?
3. При JWT-аутентификации в serverless...
4. JWT access token — где валидируется в serverless?
5. Какой хеш для пароля в Go?

🔗 Мостик к Модулю 6

API защищён: регистрация, вход, refresh-ротация, JWT на API Gateway, сквозной тест curl. Но чат без надёжного хранилища — карточный домик. В Модуле 6 разберёмся с YDB и надёжностью данных: модель данных, индексы под сценарии, транзакции, идемпотентность через request_id, повторы с backoff и circuit breaker — чтобы сообщения не терялись и не дублировались при сбоях базы.