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) }
gofunc (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):
gofunc 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/refresh | refresh | Ротация токенов |
| POST | /rooms | JWT | Создать комнату (name, type, participants) |
| GET | /rooms | JWT | Список моих комнат |
| POST | /rooms/:id/participants | JWT | Добавить участника |
| DELETE | /rooms/:id/participants/:uid | JWT | Исключить участника |
| POST | /rooms/:id/messages | JWT | Отправить сообщение (text, request_id) → 202 |
| GET | /rooms/:id/messages | JWT | История (?cursor=&limit=20) |
| WS | /ws | JWT (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).
| Событие | Направление | Данные |
|---|---|---|
message | server→client | новое сообщение в комнате |
presence | server→client | пользователь online/offline |
typing | client→server | {room_id, is_typing} |
error | server→client | ошибка доставки |
Локальная разработка
Для тестов через curl (ниже) нужен локальный сервер. В serverless мы не запускаем «Cloud Function» на своей машине — вместо этого собираем тот же Go-код в обычный HTTP-сервер и подключаем адаптеры:
docker-compose.yml (локальные эмуляторы)
yamlservices: 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
bashgo run ./cmd/server # HTTP-сервер на :8080 # или docker-compose up # поднимает YDB + API
Composition Root определяет окружение по переменным: если есть
YDB_ENDPOINT— подключается к YDB (локальному или облачному); если нет — использует in-memory адаптеры (для быстрых unit-тестов).
Миграции (создание таблиц)
Перед первым запуском примените схему (из модуля 6):
bashgo 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 отправляет сообщение
bashcurl -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 читает историю
bashcurl -s "http://localhost:8080/rooms/$ROOM_ID/messages?limit=20" \ -H "Authorization: Bearer $BOB_TOKEN" | jq .
6. Bob отвечает
bashcurl -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 видит ответ
bashcurl -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)
🧩 Проверь себя
🔗 Мостик к Модулю 6
API защищён: регистрация, вход, refresh-ротация, JWT на API Gateway, сквозной тест curl. Но чат без
надёжного хранилища — карточный домик. В Модуле 6 разберёмся с YDB и надёжностью данных: модель данных,
индексы под сценарии, транзакции, идемпотентность через request_id, повторы с backoff и circuit breaker —
чтобы сообщения не терялись и не дублировались при сбоях базы.