СОБЕСЕДОВАНИЕ в ЯНДЕКС на frontend-разработчика. Этап 3: System Design
Сегодня мы разберём расшифровку собеседования на позицию синьора в Яндекс, где кандидат Данил проходит секцию System Design, проектируя сервис вопросов и ответов (аналог Stack Overflow) с нуля. Интервьюер из команды Яндекс Директа выступает в роли продуктового менеджера, а Данил как технический эксперт обсуждает архитектуру, выбор базы данных, хранение аватарок, безопасность, API и фронтенд-реализацию. В ходе беседы кандидат демонстрирует широкий кругозор и уверенные знания, однако интервьюер отмечает некоторые пробелы в области безопасности, в результате чего Данил получает приглашение на следующий этап — «проверку опыта во фронтенде».
Вопрос 1. Какие основные сущности и их поля будут в упрощённом аналоге сервиса вопросов и ответов (типа Stack Overflow)?
Таймкод: 00:05:46
Ответ собеседника: Неполный. Выделены три сущности: Пользователь (с полями: email, password, рейтинг), Вопрос (с полями: body, список ID ответов, статус Open/Closed) и Ответ (с полями: body, лайки, флаг 'избранный ответ'). Связи: пользователь может задать вопрос и дать ответ, вопрос содержит список ответов. Гости не сохраняются в базе.
Правильный ответ:
Ответ собеседника затронул базовые сущности, но является неполным — отсутствуют важные поля и сущности, необходимые для полноценного аналога Stack Overflow.
Основные сущности и их поля
1. Пользователь (User)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
reputation INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
avatar_url TEXT,
bio TEXT,
is_active BOOLEAN DEFAULT TRUE
);
Ключевые поля, которые были упущены: username (уникальный идентификатор для отображения), password_hash (никогда не храним пароль в открытом виде — только хеш), reputation (система репутации — одна из ключевых механик Stack Overflow), created_at/updated_at (аудитные поля).
2. Вопрос (Question)
CREATE TABLE questions (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id),
title VARCHAR(300) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'open', -- open, closed, deleted
accepted_answer_id BIGINT REFERENCES answers(id),
view_count INTEGER DEFAULT 0,
score INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
Пропущенные важные поля: title (заголовок вопроса — обязательный элемент), author_id (внешний ключ на автора), accepted_answer_id (ссылка на принятый ответ), view_count (счётчик просмотров), score (общий скор вопроса).
3. Ответ (Answer)
CREATE TABLE answers (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES questions(id),
author_id BIGINT NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
score INTEGER DEFAULT 0,
is_accepted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
Пропущено: question_id (привязка к вопросу — критически важно), author_id (кто дал ответ), created_at/updated_at.
4. Тег (Tag) — пропущенная сущность
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
usage_count INTEGER DEFAULT 0
);
CREATE TABLE question_tags (
question_id BIGINT REFERENCES questions(id),
tag_id BIGINT REFERENCES tags(id),
PRIMARY KEY (question_id, tag_id)
);
Теги — фундаментальная часть Stack Overflow. Без них невозможна категоризация и поиск вопросов.
5. Голос (Vote) — пропущенная сущность
CREATE TABLE votes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
target_type VARCHAR(20) NOT NULL, -- 'question' или 'answer'
target_id BIGINT NOT NULL,
value SMALLINT NOT NULL CHECK (value IN (-1, 1)),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, target_type, target_id)
);
Система голосования должна быть отдельной сущностью, а не просто счётчиком в вопросе/ответе. Это позволяет: проверять, голосовал ли пользователь повторно; хранить историю голосов; предотвращать накрутку.
6. Комментарий (Comment) — пропущенная сущность
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id),
target_type VARCHAR(20) NOT NULL, -- 'question' или 'answer'
target_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Связи между сущностями
User 1───N Question (один пользователь — много вопросов)
User 1───N Answer (один пользователь — много ответов)
Question 1───N Answer (один вопрос — много ответов)
Question N───N Tag (многие-ко-многим через question_tags)
User 1───N Vote (один пользователь — много голосов)
User 1───N Comment (один пользователь — много комментариев)
Итоговый список пропущенных элементов в ответе собеседника:
titleв вопросе — обязательное полеauthor_id— привязка к автору в вопросах и ответах- Пароль должен храниться как
password_hash, а неpassword - Аудитные поля
created_at,updated_at - Сущность
Tagи таблица связиquestion_tags - Сущность
Voteкак отдельная таблица (не просто счётчик) - Сущность
Comment - Поле
view_countдля вопросов - Поле
is_acceptedв ответе дублирует логикуaccepted_answer_idв вопросе — достаточно одного из них
Вопрос 2. Какую базу данных выбрать для сервиса вопросов и ответов и почему? Какие альтернативы существуют и для каких задач они подходят?
Таймкод: 00:13:58
Ответ собеседника: Правильный. Выбрана PostgreSQL — реляционная СУБД, подходящая для задачи с простыми связями между сущностями. Из альтернатив рассмотрены: документо-ориентированные (MongoDB — для хранения документов с опциональными полями, подходит для фронтендеров), графовые (для сложных социальных связей), событийные (ClickHouse — для быстрой записи большого количества событий при редком чтении). PostgreSQL покрывает данный кейс полностью.
Правильный ответ:
Выбор PostgreSQL для данного кейса абсолютно обоснован. Рассмотрим подробнее причины и альтернативы.
Почему PostgreSQL — правильный выбор
1. Структурированные данные с чёткими связями
Сущности сервиса вопросов и ответов имеют строгую схему и множество связей: пользователь — вопрос — ответ — тег — голос. Реляционная модель идеально ложится на эту структуру. Связи «один-ко-многим» (пользователь → вопросы) и «многие-ко-многим» (вопросы ↔ теги) реализуются естественно через внешние ключи и промежуточные таблицы.
2. ACID-транзакции
Операции голосования требуют атомарности: нужно одновременно добавить голос и обновить счётчик рейтинга. Без транзакций возможны рассинхронизации.
BEGIN;
INSERT INTO votes (user_id, target_type, target_id, value)
VALUES (42, 'answer', 100, 1);
UPDATE answers SET score = score + 1 WHERE id = 100;
UPDATE users SET reputation = reputation + 10 WHERE id = (
SELECT author_id FROM answers WHERE id = 100
);
COMMIT;
3. Полнотекстовый поиск
PostgreSQL имеет встроенный полнотекстовый поиск через tsvector и tsquery, что критично для поиска вопросов по заголовку и содержимому.
-- Создание индекса для полнотекстового поиска
ALTER TABLE questions ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX idx_questions_search ON questions USING GIN(search_vector);
-- Поиск
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM questions, plainto_tsquery('english', 'golang goroutine') query
WHERE search_vector @@ query
ORDER BY rank DESC;
4. Зрелость и экосистема
PostgreSQL имеет десятилетия развития, богатую документацию, множество расширений (PostGIS для геоданных, pg_trgm для нечёткого поиска, pg_partman для партиционирования) и отличную поддержку в облачных провайдерах.
Альтернативные базы данных и их применимость
A. MongoDB (документо-ориентированная)
Подходит, если структура данных нестабильна и поля варьируются. Например, для хранения профилей пользователей с произвольными метаданными. Однако для нашего кейса связи между сущностями сложно реализовать эффективно — придётся делать множественные запросы или денормализовать данные, что ведёт к проблемам с консистентностью.
B. Redis (in-memory key-value)
Не подходит как основная БД, но отлично дополняет PostgreSQL для кэширования горячих вопросов, хранения сессий и реализации рейтингов через sorted sets.
// Пример: кэширование топ-вопросов в Redis
func GetTopQuestions(ctx context.Context, rdb *redis.Client) ([]Question, error) {
// Пробуем получить из кэша
cached, err := rdb.Get(ctx, "top_questions").Result()
if err == nil {
var questions []Question
json.Unmarshal([]byte(cached), &questions)
return questions, nil
}
// Если нет в кэше — идём в PostgreSQL
questions := fetchFromPostgres()
data, _ := json.Marshal(questions)
rdb.Set(ctx, "top_questions", data, 5*time.Minute)
return questions, nil
}
C. Elasticsearch (поисковый движок)
Идеален для полнотекстового поиска с фасетами, подсветкой совпадений и сложными фильтрами. На продакшене Stack Overflow использует Elasticsearch именно для поиска, а PostgreSQL — как основное хранилище.
D. ClickHouse (колоночная СУБД)
Подходит для аналитики: подсчёт статистики по голосам, анализ активности пользователей, построение отчётов. Не подходит как основная БД из-за отсутствия транзакций и медленных точечных обновлений.
E. Neo4j (графовая БД)
Полезна, если нужно анализировать сложные социальные связи: «пользователи, которые отвечали на те же вопросы, что и вы», «рекомендации вопросов на основе графа интересов». Для базового функционала избыточна.
Рекомендуемая архитектура для продакшена
PostgreSQL (основное хранилище)
├── Redis (кэш, сессии, рейтинги)
├── Elasticsearch (полнотекстовый поиск)
└── ClickHouse (аналитика)
PostgreSQL покрывает ядро приложения, а специализированные системы дополняют его там, где он слабее.
Вопрос 3. Как хранить аватарки пользователей и какую технологию для этого использовать?
Таймкод: 00:19:09
Ответ собеседника: Неполный. Аватарки не следует хранить в базе данных. Предложено использовать отдельное файловое хранилище (blob storage) для статики, например, S3-совместимое решение. Кандидат не смог вспомнить конкретное название технологии, но верно указал на необходимость отдельного хранения файлов.
Правильный ответ:
Направление верное — аватарки действительно не должны храниться в базе данных. Но ответ требует существенного дополнения конкретными технологиями и архитектурными решениями.
Почему нельзя хранить аватарки в БД
Хранение бинарных файлов в PostgreSQL (даже через BYTEA или large object) приводит к раздуванию базы, замедлению бэкапов, увеличению потребления памяти при запросах. База данных — для структурированных данных, а не для бинарного контента.
Технологии для хранения аватарок
A. Amazon S3 (или S3-совместимые решения)
AWS S3 — стандарт де-факто для хранения объектов. Аналоги: MinIO (self-hosted, полностью совместим с S3 API), Yandex Object Storage, Selectel.
// Загрузка аватарки в S3 через AWS SDK v2
func UploadAvatar(ctx context.Context, client *s3.Client, userID string, file io.Reader) (string, error) {
key := fmt.Sprintf("avatars/%s/%s.jpg", userID, uuid.New().String())
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("my-app-bucket"),
Key: aws.String(key),
Body: file,
ContentType: aws.String("image/jpeg"),
// Ограничиваем доступ — аватарки публичные
ACL: types.ObjectCannedACLPublicRead,
})
if err != nil {
return "", fmt.Errorf("failed to upload avatar: %w", err)
}
return fmt.Sprintf("https://cdn.example.com/%s", key), nil
}
B. MinIO для self-hosted решений
Если инфраструктура разворачивается на собственных серверах, MinIO предоставляет S3-совместимый API без зависимости от облачного провайдера.
// MinIO использует тот же AWS SDK, только меняется endpoint
minioClient, err := minio.New("minio.example.com", &minio.Options{
Creds: credentials.NewStaticV4("accessKey", "secretKey", ""),
Secure: true,
})
C. CDN (Content Delivery Network)
Аватарки — статический контент, который читается гораздо чаще, чем пишется. CDN (CloudFront, Cloudflare, Yandex CDN) кэширует файлы на edge-серверах по всему миру, снижая латентность для пользователей.
Пользователь → CDN edge (кэш) → S3 origin (если кэш пуст)
Архитектура хранения аватарок
1. Множественные размеры
Загружать одну картинку и генерировать несколько размеров — распространённая практика.
type AvatarSize string
const (
AvatarSmall AvatarSize = "64x64"
AvatarMedium AvatarSize = "128x128"
AvatarLarge AvatarSize = "256x256"
)
func GenerateAvatarVariants(ctx context.Context, original []byte) (map[AvatarSize][]byte, error) {
img, err := imaging.Decode(bytes.NewReader(original))
if err != nil {
return nil, err
}
variants := make(map[AvatarSize][]byte)
sizes := map[AvatarSize]struct{ w, h int }{
AvatarSmall: {64, 64},
AvatarMedium: {128, 128},
AvatarLarge: {256, 256},
}
for size, dims := range sizes {
resized := imaging.Fill(img, dims.w, dims.h, imaging.Center, imaging.Lanczos)
var buf bytes.Buffer
if err := imaging.Encode(&buf, resized, imaging.JPEG, imaging.JPEGQuality(85)); err != nil {
return nil, err
}
variants[size] = buf.Bytes()
}
return variants, nil
}
2. Схема хранения в БД
В таблице users хранится только ссылка на аватарку, а не сам файл.
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- Пример значения: https://cdn.example.com/avatars/42/abc-123-64x64.jpg
3. Валидация при загрузке
func ValidateAvatar(file io.Reader) error {
// Проверка размера (максимум 5 МБ)
data, err := io.ReadAll(io.LimitReader(file, 5*1024*1024+1))
if err != nil {
return err
}
if len(data) > 5*1024*1024 {
return fmt.Errorf("file too large: max 5MB")
}
// Проверка MIME-типа по содержимому, а не по расширению
contentType := http.DetectContentType(data)
allowed := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
if !allowed[contentType] {
return fmt.Errorf("unsupported file type: %s", contentType)
}
return nil
}
4. Обработка загрузки на стороне бэкенда vs прямая загрузка в S3
Два подхода:
Прямая загрузка (pre-signed URL): Бэкенд генерирует временную подписанную ссылку, фронтенд загружает файл напрямую в S3. Снимает нагрузку с бэкенда.
func GeneratePresignedUploadURL(ctx context.Context, client *s3.PresignClient, userID string) (string, error) {
req, err := client.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("my-app-bucket"),
Key: aws.String(fmt.Sprintf("avatars/%s/%s", userID, uuid.New().String())),
}, s3.WithPresignExpires(15*time.Minute))
if err != nil {
return "", err
}
return req.URL, nil
}
Загрузка через бэкенд: Фронтенд отправляет файл на сервер, сервер валидирует, обрабатывает и загружает в S3. Больше контроля, но выше нагрузка на сервер.
Полная схема архитектуры
Пользователь → API Gateway → Backend (валидация, генерация размеров)
↓
S3 / MinIO (хранение)
↓
CDN (раздача с edge-кэшем)
Итог: S3-совместимое хранилище (Amazon S3 или MinIO) + CDN + генерация нескольких размеров + хранение только URL в PostgreSQL. Это стандартный подход для продакшен-приложений.
Вопрос 4. Как хранить пароли пользователей? Что такое соль (salt) и зачем она нужна?
Таймкод: 00:21:17
Ответ собеседника: Правильный. Пароль должен храниться в зашифрованном виде с использованием алгоритма хеширования и соли. Соль — это случайно сгенерированная строка, которая добавляется к паролю перед хешированием. Это усложняет взлом, так как злоумышленник, даже зная алгоритм хеширования, не знает соль и не может использовать предварительно рассчитанные таблицы хешей (радужные таблицы). Соль хранится на сервере и делает каждый хеш уникальным даже для одинаковых паролей.
Правильный ответ:
Ответ собеседника в целом верный, но стоит уточнить несколько важных деталей и дополнить конкретными алгоритмами и примерами.
Почему хеширование, а не шифрование
Собеседник использовал слово «зашифрованный», но это неточно. Пароли хранятся в виде хеша, а не шифра. Разница принципиальная: шифрование обратимо (можно расшифровать), хеширование — нет. Сервер никогда не должен знать исходный пароль пользователя.
Что такое соль (salt)
Соль — это случайная строка фиксированной длины, которая генерируется заново для каждого пользователя и добавляется к паролю перед хешированием. Задача соли — гарантировать, что одинаковые пароли дают разные хеши.
Проблема без соли:
password123 → SHA256 → ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
password123 → SHA256 → ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
Одинаковый хеш — злоумышленник видит, что у двух пользователей одинаковый пароль, и может использовать радужные таблицы.
С солью:
salt: "a1b2c3" + password123 → хеш 1: 7f3a...
salt: "x9y8z7" + password123 → хеш 2: 2e9b...
Современные алгоритмы хеширования паролей
Простой SHA-256 с солью уже недостаточен. Нужны алгоритмы, специально разработанные для хеширования паролей — они вычислительно затратные, что замедляет перебор.
A. bcrypt
Стандарт де-факто. Встроенно управляет солью, имеет параметр cost (количество итераций).
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
// cost=12 означает 2^12 = 4096 итераций
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
return string(bytes), err
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// Пример хеша: $2a$12$LJ3m4ys3Lg3Lg3Lg3Lg3LuOeXampleHashStringHere
// $2a$ — идентификатор алгоритма bcrypt
// 12 — параметр cost
// Далее — соль (22 символа) + хеш (31 символ)
B. Argon2
Победитель конкурса Password Hashing Competition (2015). Считается наиболее безопасным алгоритмом на сегодняшний день. Устойчив к GPU-атакам благодаря memory-hard свойству.
import "golang.org/x/crypto/argon2"
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
// Параметры: time=3, memory=64MB, threads=4, keyLen=32
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
// Храним всё вместе: алгоритм, параметры, соль, хеш
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, 64*1024, 3, 4, b64Salt, b64Hash)
return encodedHash, nil
}
C. scrypt
Альтернатива bcrypt с memory-hard свойством. Менее распространена, но тоже допустима.
Почему не SHA-256 или MD5
Эти алгоритмы слишком быстрые. Современная GPU может перебирать миллиарды SHA-256 хешей в секунду. bcrypt с cost=12 даёт около 100 хешей в секунду на том же оборудовании — разница в миллиарды раз.
Что хранить в базе данных
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- хранится ВСЁ: алгоритм, соль, параметры, хеш
created_at TIMESTAMP DEFAULT NOW()
);
Соль хранится внутри строки хеша (bcrypt и Argon2 делают это автоматически). Не нужно создавать отдельную колонку для соли.
Дополнительные меры безопасности
- Pepper — глобальный секрет, добавляемый к паролю помимо соли, хранящийся не в БД, а в конфигурации приложения (или HSM). Даже при утечке БД без pepper хеши не взломать.
- Rate limiting на эндпоинте логина для защиты от онлайн-перебора.
- Хеширование на стороне клиента (опционально) — дополнительный уровень защиты, но не заменяет серверное хеширование.
Итого: Использовать bcrypt (cost ≥ 12) или Argon2id. Соль генерируется автоматически алгоритмом и хранится внутри строки хеша. Никогда не использовать SHA-256/MD5 для паролей — они слишком быстрые для перебора.
Вопрос 5. Какие компоненты должны входить в общую архитектурную схему сервиса вопросов и ответов?
Таймкод: 00:28:34
Ответ собеседника: Неполный. В схему включены: Frontend (Nginx), Backend, база данных (PostgreSQL), файловое хранилище (S3/Blob Storage для аватарок), система мониторинга (Prometheus/Grafana), система логирования (Elasticsearch), CDN/SSG Content для предварительно сгенерированных страниц, система метрик (Яндекс.Метрика/Google Analytics). Предложено разделение на регионы с балансировкой нагрузки.
Правильный ответ:
Ответ затронул многие важные компоненты, но не содержит детализации внутренней структуры бэкенда, отсутствуют кэширующий слой, очередь сообщений, поисковый движок и не описаны связи между компонентами.
Полная архитектурная схема
A. Клиентский уровень
- CDN (CloudFront / Cloudflare / Yandex CDN) — раздача статики (CSS, JS, аватарки, изображения в ответах). Кэширование на edge-серверах снижает латентность.
- Nginx (reverse proxy) — терминация SSL, rate limiting, сжатие gzip/brotli, маршрутизация запросов к бэкенду, раздача статических файлов.
- Frontend приложение — SPA (React/Vue) или SSR для SEO-критичных страниц (списки вопросов, страницы вопросов).
B. Уровень бэкенда
Бэкенд должен быть разделён на несколько сервисов, а не представлять собой монолит:
- API Gateway — единая точка входа для всех клиентских запросов. Маршрутизация, аутентификация на уровне gateway, rate limiting, валидация входящих запросов.
- Question Service — сервис вопросов и ответов. CRUD операций с вопросами, ответами, голосами, комментариями.
- User Service — регистрация, аутентификация, профили пользователей, управление репутацией.
- Search Service — интеграция с Elasticsearch для полнотекстового поиска по вопросам и ответам.
- Notification Service — уведомления пользователям о новых ответах, комментариях, изменениях репутации.
- Media Service — загрузка и обработка изображений (аватарки, скриншоты в вопросах).
// Пример структуры API Gateway на Go
type APIGateway struct {
questionClient questionv1.QuestionServiceClient
userClient userv1.UserServiceClient
searchClient searchv1.SearchServiceClient
authMiddleware *AuthMiddleware
rateLimiter *RateLimiter
}
func (g *APIGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Rate limiting
if !g.rateLimiter.Allow(r.RemoteAddr) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// Аутентификация
claims, err := g.authMiddleware.Authenticate(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Маршрутизация к соответствующему сервису
switch {
case strings.HasPrefix(r.URL.Path, "/api/questions"):
g.proxyToQuestionService(w, r, claims)
case strings.HasPrefix(r.URL.Path, "/api/users"):
g.proxyToUserService(w, r, claims)
case strings.HasPrefix(r.URL.Path, "/api/search"):
g.proxyToSearchService(w, r, claims)
}
}
C. Уровень данных
- PostgreSQL — основное хранилище структурированных данных. ACID-транзакции для голосования, чёткие связи между сущностями.
- Redis — кэширование горячих данных (топ вопросы, сессии пользователей, rate limiting счётчики, временные данные).
- Elasticsearch — полнотекстовый поиск с подсветкой совпадений, фасетами, фильтрацией по тегам и датам.
- S3 / MinIO — хранение аватарок и загруженных изображений.
D. Очередь сообщений
- Kafka / RabbitMQ — асинхронная обработка событий. При создании вопроса — отправка события в очередь, откуда Search Service забирает его для индексации в Elasticsearch. Notification Service слушает события о новых ответах.
// Пример: публикация события о новом вопросе в Kafka
type QuestionCreatedEvent struct {
QuestionID int64 `json:"question_id"`
AuthorID int64 `json:"author_id"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
func (s *QuestionService) CreateQuestion(ctx context.Context, req *CreateQuestionRequest) (*Question, error) {
// Сохраняем в PostgreSQL
question, err := s.repo.CreateQuestion(ctx, req)
if err != nil {
return nil, err
}
// Публикуем событие для индексации и уведомлений
event := QuestionCreatedEvent{
QuestionID: question.ID,
AuthorID: question.AuthorID,
Title: question.Title,
Tags: req.Tags,
CreatedAt: question.CreatedAt,
}
if err := s.eventPublisher.Publish(ctx, "question.created", event); err != nil {
// Логируем, но не прерываем — eventual consistency
s.log.Error("failed to publish event", "error", err)
}
return question, nil
}
E. Наблюдаемость (Observability)
- Prometheus + Grafana — сбор и визуализация метрик (RPS, latency, error rate, размер очередей).
- ELK Stack (Elasticsearch + Logstash + Kibana) или Loki + Grafana — централизованное логирование.
- Jaeger / Zipkin — распределённая трассировка для отслеживания запросов через микросервисы.
- Sentry — отслеживание ошибок в реальном времени.
F. Инфраструктурный уровень
- Docker + Kubernetes — контейнеризация и оркестрация. Автоматическое масштабирование (HPA) на основе CPU/RPS.
- CI/CD (GitLab CI / GitHub Actions) — автоматические тесты, сборка, деплой.
- HashiCorp Vault — управление секретами (ключи API, пароли от БД, сертификаты).
Схема взаимодействия компонентов
┌─────────┐
│ CDN │
└────┬────┘
│
┌────▼────┐
│ Nginx │ (SSL, rate limit, gzip)
└────┬────┘
│
┌────▼────────┐
│ API Gateway │ (маршрутизация, auth)
└────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
│Question │ │ User │ │ Search │
│ Service │ │ Service │ │ Service │
└────┬────┘ └─────┬─────┘ └────┬─────┘
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
│PostgreSQL│ │PostgreSQL │ │Elastic- │
│(primary) │ │ (shared) │ │ search │
└────┬────┘ └───────────┘ └──────────┘
│
┌────▼────┐ ┌───────────┐ ┌──────────┐
│ Redis │ │ Kafka │ │ S3 │
│ (cache) │ │ (events) │ │ (files) │
└─────────┘ └───────────┘ └──────────┘
Чего не хватало в ответе собеседника:
- Разделение бэкенда на сервисы (микросервисы или модульный монолит)
- API Gateway как отдельный компонент
- Redis для кэширования и сессий
- Очередь сообщений (Kafka/RabbitMQ) для асинхронной обработки
- Elasticsearch как отдельный компонент поиска
- Распределённая трассировка
- Система управления секретами
- CI/CD pipeline
- Описание связей между компонентами
Вопрос 6. Какой протокол взаимодействия между фронтендом и бэкендом использовать (REST, GraphQL, WebSockets, gRPC)?
Таймкод: 00:50:20
Ответ собеседника: Неполный. Для основного взаимодействия выбран REST как наиболее подходящий для стандартного сервиса. Для real-time функционала (обновления ответов) предложены WebSockets с двунаправленной связью. Упомянут GraphQL с его преимуществами (единая схема, опциональные поля), но отмечены потенциальные накладные расходы. gRPC/Protobuf упомянуты для микросервисов, но с оговоркой о проблемах с парсингом в Node.js. Кандидат не смог четко описать ограничения GraphQL.
Правильный ответ:
Выбор протокола зависит от контекста взаимодействия. Для сервиса вопросов и ответов оптимально комбинировать несколько протоколов в зависимости от задачи.
A. REST — основной протокол для CRUD
REST подходит для большинства операций с вопросами, ответами, пользователями. Он прост, хорошо документируется через OpenAPI/Swagger, легко кэшируется на уровне HTTP.
// REST API для вопросов
func (s *QuestionService) RegisterRoutes(r *chi.Mux) {
r.Route("/api/v1/questions", func(r chi.Router) {
r.Get("/", s.ListQuestions) // GET /api/v1/questions?tag=golang&page=1
r.Post("/", s.CreateQuestion) // POST /api/v1/questions
r.Get("/{id}", s.GetQuestion) // GET /api/v1/questions/42
r.Put("/{id}", s.UpdateQuestion) // PUT /api/v1/questions/42
r.Delete("/{id}", s.DeleteQuestion) // DELETE /api/v1/questions/42
r.Post("/{id}/answers", s.CreateAnswer) // POST /api/v1/questions/42/answers
r.Post("/{id}/vote", s.VoteQuestion) // POST /api/v1/questions/42/vote
})
}
// Пример обработчика с пагинацией и фильтрацией
func (s *QuestionService) ListQuestions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Параметры запроса
tag := r.URL.Query().Get("tag")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
pageSize := 20
offset := (page - 1) * pageSize
questions, total, err := s.repo.ListQuestions(ctx, ListQuestionsFilter{
Tag: tag,
Limit: pageSize,
Offset: offset,
})
if err != nil {
respondError(w, http.StatusInternalServerError, err)
return
}
respondJSON(w, http.StatusOK, PaginatedResponse{
Data: questions,
Total: total,
Page: page,
Size: pageSize,
})
}
Преимущества REST для нашего кейса: простота реализации, нативный кэширование через HTTP-заголовки (Cache-Control, ETag), понятная семантика методов (GET/POST/PUT/DELETE), лёгкая интеграция с CDN.
B. GraphQL — для сложных запросов с гибкой структурой
GraphQL решает проблему over-fetching и under-fetching. Когда фронтенду нужны вопросы с ответами, тегами и информацией об авторе — в REST пришлось бы делать несколько запросов или создавать кастомные эндпоинты.
# GraphQL запрос: получить вопрос с ответами и тегами одним запросом
query GetQuestion($id: ID!) {
question(id: $id) {
id
title
body
author {
id
displayName
reputation
avatarUrl
}
tags {
id
name
}
answers {
id
body
score
isAccepted
author {
id
displayName
reputation
}
}
score
viewCount
createdAt
}
}
// Реализация GraphQL resolver на Go с использованием gqlgen
type questionResolver struct{ *Resolver }
func (r *questionResolver) Question(ctx context.Context, id int64) (*model.Question, error) {
return r.QuestionRepo.GetByID(ctx, id)
}
func (r *questionResolver) Answers(ctx context.Context, question *model.Question) ([]*model.Answer, error) {
// DataLoader решает проблему N+1
return r.AnswerLoader.Load(ctx, question.ID)
}
// DataLoader для решения проблемы N+1 запросов
type AnswerLoader struct {
repo *AnswerRepository
}
func (l *AnswerLoader) BatchGet(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
questionIDs := make([]int64, len(keys))
for i, key := range keys {
questionIDs[i] = key.Raw().(int64)
}
// Один запрос вместо N
answersByQuestion, err := l.repo.ListByQuestionIDs(ctx, questionIDs)
results := make([]*dataloader.Result, len(keys))
for i, id := range questionIDs {
results[i] = &dataloader.Result{Data: answersByQuestion[id]}
}
return results
}
Ключевые ограничения GraphQL, которые не были раскрыты:
- Проблема N+1 запросов: Без DataLoader каждый вложенный объект генерирует отдельный запрос в БД.
- Сложность кэширования: REST кэшируется на уровне HTTP стандартно. GraphQL использует один POST-эндпоинт, что ломает HTTP-кэширование — нужны persisted queries или Apollo Cache.
- Сложность rate limiting: В REST лимиты считаются по количеству запросов. В GraphQL один запрос может быть лёгким, а другой — загружать тысячи объектов. Нужен расчёт complexity score.
- File upload: GraphQL не имеет нативной поддержки загрузки файлов — нужны дополнительные решения (multipart spec).
- Сложность мониторинга: Все запросы идут в один эндпоинт, сложнее анализировать метрики по конечным точкам.
C. WebSockets — для real-time обновлений
Для мгновенного отображения новых ответов, комментариев и изменений рейтинга без перезагрузки страницы.
// WebSocket сервер для real-time обновлений
type Hub struct {
clients map[int64]map[*Client]bool // userID → клиенты
broadcast chan *RealtimeEvent
register chan *Client
unregister chan *Client
}
type RealtimeEvent struct {
Type string `json:"type"` // "new_answer", "vote_update", "comment_added"
QuestionID int64 `json:"question_id"`
Payload json.RawMessage `json:"payload"`
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
if h.clients[client.userID] == nil {
h.clients[client.userID] = make(map[*Client]bool)
}
h.clients[client.userID][client] = true
case client := <-h.unregister:
if clients, ok := h.clients[client.userID]; ok {
delete(clients, client)
close(client.send)
}
case event := <-h.broadcast:
// Отправляем всем клиентам, которые просматривают этот вопрос
for _, clients := range h.clients {
for client := range clients {
if client.watchingQuestions[event.QuestionID] {
select {
case client.send <- event:
default:
close(client.send)
delete(clients, client)
}
}
}
}
}
}
}
// Отправка события при новом ответе
func (s *QuestionService) notifyNewAnswer(ctx context.Context, questionID int64, answer *Answer) {
event := RealtimeEvent{
Type: "new_answer",
QuestionID: questionID,
Payload: mustMarshal(answer),
}
s.hub.broadcast <- &event
}
D. gRPC — для межсервисного взаимодействия
gRPC не подходит для фронтенд-бэкенд коммуникации (браузеры не поддерживают HTTP/2 streaming напрямую без gRPC-Web), но идеален для общения между микросервисами внутри бэкенда.
// question_service.proto
syntax = "proto3";
package question.v1;
service QuestionService {
rpc CreateQuestion(CreateQuestionRequest) returns (Question);
rpc GetQuestion(GetQuestionRequest) returns (Question);
rpc ListQuestions(ListQuestionsRequest) returns (ListQuestionsResponse);
rpc StreamQuestionUpdates(StreamQuestionUpdatesRequest) returns (stream QuestionUpdate);
}
message CreateQuestionRequest {
int64 author_id = 1;
string title = 2;
string body = 3;
repeated string tags = 4;
}
message Question {
int64 id = 1;
int64 author_id = 2;
string title = 3;
string body = 4;
int32 score = 5;
string status = 6;
repeated string tags = 7;
google.protobuf.Timestamp created_at = 8;
}
// gRPC клиент для межсервисного взаимодействия
type QuestionGRPCClient struct {
conn *grpc.ClientConn
client questionv1.QuestionServiceClient
}
func NewQuestionGRPCClient(addr string) (*QuestionGRPCClient, error) {
conn, err := grpc.Dial(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)),
)
if err != nil {
return nil, err
}
return &QuestionGRPCClient{
conn: conn,
client: questionv1.NewQuestionServiceClient(conn),
}, nil
}
func (c *QuestionGRPCClient) GetQuestion(ctx context.Context, id int64) (*questionv1.Question, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return c.client.GetQuestion(ctx, &questionv1.GetQuestionRequest{Id: id})
}
Рекомендуемая комбинация для сервиса:
| Взаимодействие | Протокол | Обоснование |
|---|---|---|
| Фронтенд ↔ Бэкенд (основное) | REST | Простота, кэширование, документация |
| Фронтенд ↔ Бэкенд (сложные запросы) | GraphQL | Гибкость получения вложенных данных |
| Фронтенд ↔ Бэкенд (real-time) | WebSockets | Мгновенные обновления ответов и голосов |
| Бэкенд ↔ Бэкенд | gRPC | Производительность, строгая типизация, streaming |
Итого: REST как основной протокол для CRUD-операций, GraphQL для сложных запросов с вложенными сущностями (вопрос + ответы + авторы + теги одним запросом), WebSockets для real-time обновлений на странице вопроса, gRPC для внутренней коммуникации между сервисами. Использование только одного протокола — признак недостаточной проработки архитектуры.
Вопрос 7. Как защитить сервис от вредоносных действий пользователей (XSS, DDoS, спам, инъекции)?
Таймкод: 01:19:38
Ответ собеседника: Неполный. Для защиты от XSS предложено экранировать пользовательский ввод и использовать белый список разрешённых символов на фронте и бэкенде. React по умолчанию экранирует HTML, но есть опасность через dangerouslySetInnerHTML. Для защиты от DDoS предложен антифрод-сервис, отслеживающий заголовки запросов и определяющий ботов, а также бан пользователей при превышении лимита запросов. Для защиты от токсичного поведения предложен механизм жалоб с автоматической блокировкой при превышении порога жалоб. Кандидат не смог объяснить механизм SQL-инъекций.
Правильный ответ:
Ответ затронул отдельные аспекты безопасности, но не содержит системного подхода. Рассмотрим полный спектр угроз и способов защиты.
A. XSS (Cross-Site Scripting)
XSS позволяет злоумышленнику внедрить вредоносный JavaScript в страницу, которую увидят другие пользователи. В контексте сервиса вопросов и ответов это критично — пользователи пишут HTML-содержимое в вопросах и ответах.
Типы XSS:
- Stored XSS — вредоносный код сохраняется в БД (например, в теле вопроса) и выполняется у каждого посетителя.
- Reflected XSS — вредоносный код передаётся через URL-параметры и отражается в ответе сервера.
- DOM-based XSS — уязвимость в клиентском JavaScript, который небезопасно обрабатывает данные.
Защита на бэкенде:
import "html"
// Базовое экранирование — для простого текста
func SanitizeText(input string) string {
return html.EscapeString(input)
}
// Для вопросов и ответов нужен более тонкий подход:
// пользователи должны иметь возможность использовать форматирование (код, жирный текст)
// но не должны внедрять скрипты
import (
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
)
// Разрешённые HTML-теги для вопросов и ответов
var allowedPolicy = bluemonday.UGCPolicy()
// Разрешаем дополнительные теги для форматирования кода
allowedPolicy.AllowElements("pre", "code", "blockquote", "br", "hr")
allowedPolicy.AllowAttrs("class").OnElements("pre", "code") // для подсветки синтаксиса
// Запрещаем все event-обработчики
allowedPolicy.AllowNoAttrs().Globally()
func SanitizeHTML(input string) string {
return allowedPolicy.Sanitize(input)
}
// Пример использования при создании вопроса
func (s *QuestionService) CreateQuestion(ctx context.Context, req *CreateQuestionRequest) (*Question, error) {
// Валидация длины
if len(req.Body) > 50000 {
return nil, fmt.Errorf("body too long: max 50000 characters")
}
// Санитизация HTML
sanitizedBody := SanitizeHTML(req.Body)
question := &Question{
AuthorID: req.AuthorID,
Title: html.EscapeString(req.Title), // заголовок — только текст
Body: sanitizedBody,
}
return s.repo.CreateQuestion(ctx, question)
}
Защита через Content Security Policy:
// Middleware для установки CSP-заголовков
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSP запрещает выполнение инлайн-скриптов и загрузку ресурсов с неизвестных доменов
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'nonce-{random}'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' https://cdn.example.com data:; "+
"connect-src 'self' https://api.example.com; "+
"frame-ancestors 'none';")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
B. SQL-инъекции
SQL-инъекция позволяет злоумышленнику выполнить произвольный SQL-запрос через пользовательский ввод. Это одна из самых опасных уязвимостей.
Пример уязвимого кода:
// ❌ НИКОГДА ТАК НЕ ДЕЛАТЬ
func (r *QuestionRepository) SearchQuestions(ctx context.Context, searchTerm string) ([]Question, error) {
// Прямая конкатенация пользовательского ввода в SQL!
query := fmt.Sprintf("SELECT * FROM questions WHERE title LIKE '%%%s%%'", searchTerm)
rows, err := r.db.QueryContext(ctx, query)
// ...
}
Если пользователь введёт: '; DROP TABLE questions; --, результат будет катастрофическим.
Защита — параметризованные запросы:
// ✅ Правильно: параметризованный запрос
func (r *QuestionRepository) SearchQuestions(ctx context.Context, searchTerm string) ([]Question, error) {
query := "SELECT id, title, body, author_id, score, created_at FROM questions WHERE title ILIKE $1"
searchPattern := "%" + searchTerm + "%"
rows, err := r.db.QueryContext(ctx, query, searchPattern)
if err != nil {
return nil, err
}
defer rows.Close()
var questions []Question
for rows.Next() {
var q Question
if err := rows.Scan(&q.ID, &q.Title, &q.Body, &q.AuthorID, &q.Score, &q.CreatedAt); err != nil {
return nil, err
}
questions = append(questions, q)
}
return questions, nil
}
Защита через ORM/Query Builder:
// Использование squirrel для безопасной сборки запросов
import "github.com/Masterminds/squirrel"
func (r *QuestionRepository) ListQuestions(ctx context.Context, filter ListQuestionsFilter) ([]Question, error) {
builder := squirrel.Select("id", "title", "body", "author_id", "score", "created_at").
From("questions").
Where(squirrel.Eq{"status": "open"}).
OrderBy("created_at DESC").
Limit(uint64(filter.Limit)).
Offset(uint64(filter.Offset))
if filter.Tag != "" {
builder = builder.Where(squirrel.Expr("id IN (SELECT question_id FROM question_tags WHERE tag_id = (SELECT id FROM tags WHERE name = ?))", filter.Tag))
}
query, args, err := builder.ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
// ...
}
C. DDoS-атаки
Защита на уровне инфраструктуры:
- CDN (Cloudflare, AWS Shield) — поглощают объёмные атаки на сетевом уровне до того, как трафик достигнет сервера.
- Rate limiting — ограничение количества запросов с одного IP/пользователя.
// Rate limiter на основе Redis
type RateLimiter struct {
redis *redis.Client
}
func (rl *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
pipe := rl.redis.Pipeline()
now := time.Now().UnixNano()
windowStart := now - window.Nanoseconds()
// Удаляем устаревшие записи
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
// Считаем текущее количество запросов
pipe.ZCard(ctx, key)
// Добавляем текущий запрос
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
// Устанавливаем TTL на ключ
pipe.Expire(ctx, key, window)
results, err := pipe.Exec(ctx)
if err != nil {
return false, err
}
count := results[1].(*redis.IntCmd).Val()
return count <= int64(limit), nil
}
// Middleware для rate limiting
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Лимит: 100 запросов в минуту с одного IP
allowed, err := limiter.Allow(r.Context(), "ratelimit:"+r.RemoteAddr, 100, time.Minute)
if err != nil || !allowed {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Разные лимиты для разных эндпоинтов:
// Строгие лимиты для эндпоинтов, создающих нагрузку
// POST /api/questions — 5 запросов в минуту (создание вопроса)
// POST /api/answers — 10 запросов в минуту (создание ответа)
// GET /api/questions — 100 запросов в минуту (чтение)
// POST /api/vote — 30 запросов в минуту (голосование)
D. Спам и токсичный контент
// Многоуровневая система защиты от спама
// 1. Капча для новых пользователей
func (s *QuestionService) CreateQuestion(ctx context.Context, req *CreateQuestionRequest) (*Question, error) {
user, err := s.userRepo.GetByID(ctx, req.AuthorID)
if err != nil {
return nil, err
}
// Пользователи с репутацией < 10 должны пройти капчу
if user.Reputation < 10 {
if err := s.captchaService.Verify(ctx, req.CaptchaToken); err != nil {
return nil, fmt.Errorf("captcha verification failed: %w", err)
}
}
// 2. Проверка на спам через внешний сервис
spamScore, err := s.spamDetector.Check(ctx, req.Title+" "+req.Body)
if err != nil {
return nil, err
}
if spamScore > 0.8 {
// Отправляем на модерацию, не публикуем сразу
return s.moderationRepo.CreatePendingQuestion(ctx, req)
}
// 3. Проверка частоты публикаций
recentCount, err := s.repo.CountRecentQuestions(ctx, req.AuthorID, time.Hour)
if err != nil {
return nil, err
}
if recentCount >= 5 {
return nil, fmt.Errorf("rate limit: max 5 questions per hour")
}
return s.repo.CreateQuestion(ctx, req)
}
E. CSRF (Cross-Site Request Forgery)
// CSRF-защита через токены
func CSRFMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Для GET-запросов не требуется
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
token := r.Header.Get("X-CSRF-Token")
if token == "" {
http.Error(w, "missing CSRF token", http.StatusForbidden)
return
}
// Валидация токена
if !validateCSRFToken(token, secret) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
F. Дополнительные меры безопасности
- Валидация входных данных на уровне DTO с использованием библиотек вроде
go-playground/validator:
type CreateQuestionRequest struct {
Title string `json:"title" validate:"required,min=15,max=300"`
Body string `json:"body" validate:"required,min=30,max=50000"`
Tags []string `json:"tags" validate:"required,min=1,max=5,dive,alphanum"`
}
func (s *QuestionService) CreateQuestion(ctx context.Context, req *CreateQuestionRequest) (*Question, error) {
if err := s.validator.Struct(req); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
// ...
}
- Аудит безопасности — логирование всех подозрительных действий (множественные неудачные логины, попытки доступа к чужим ресурсам).
- Принцип минимальных привилегий — пользователь может редактировать только свои вопросы и ответы, модератор — любые, но не имеет доступа к админ-функциям.
- HTTPS везде — принудительное перенаправление с HTTP на HTTPS, HSTS-заголовок.
Итого: Безопасность — это многослойная система. XSS предотвращается санитизацией HTML и CSP-заголовками. SQL-инъекции — параметризованными запросами. DDoS — CDN и rate limiting. Спам — капчей, проверкой частоты действий и ML-фильтрами. Ни один из этих слоёв по отдельности не даёт полной защиты — только их комбинация.
Вопрос 8. Как организована аутентификация и авторизация пользователей? Где хранятся токены?
Таймкод: 01:30:04
Ответ собеседника: Правильный. Предложена классическая авторизация с парой токенов: access token (короткоживущий) и refresh token (долгоживущий). Access token хранится в cookie с флагами HttpOnly и Secure для безопасности. Используется HTTPS протокол. Настроен SameSite для защиты от CSRF. Бэкенд самостоятельно читает куки, что повышает безопасность.
Правильный ответ:
Ответ собеседника верный по сути, но требует детализации — нужно описать полный поток аутентификации, структуру токенов, механизм обновления и отзыва.
Полный поток аутентификации
1. Регистрация и логин
type AuthService struct {
userRepo *UserRepository
tokenService *TokenService
passwordSvc *PasswordService
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // секунд до истечения access token
}
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*TokenPair, error) {
// 1. Находим пользователя по email
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
// Важно: не раскрываем, существует ли email в системе
return nil, fmt.Errorf("invalid credentials")
}
// 2. Проверяем пароль
if !s.passwordSvc.Check(req.Password, user.PasswordHash) {
// Логируем неудачную попытку для обнаружения брутфорса
s.log.Warn("failed login attempt", "email", req.Email)
return nil, fmt.Errorf("invalid credentials")
}
// 3. Проверяем, не заблокирован ли аккаунт
if !user.IsActive {
return nil, fmt.Errorf("account is deactivated")
}
// 4. Генерируем пару токенов
tokenPair, err := s.tokenService.GenerateTokenPair(user.ID, user.Email)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
// 5. Сохраняем refresh token в БД (для возможности отзыва)
if err := s.tokenService.StoreRefreshToken(ctx, user.ID, tokenPair.RefreshToken); err != nil {
return nil, err
}
return tokenPair, nil
}
2. Структура JWT access token
import "github.com/golang-jwt/jwt/v5"
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
// Не кладём в токен чувствительные данные
jwt.RegisteredClaims
}
type TokenService struct {
accessSecret []byte
refreshSecret []byte
accessTTL time.Duration // 15 минут
refreshTTL time.Duration // 7 дней
}
func (s *TokenService) GenerateTokenPair(userID int64, email string) (*TokenPair, error) {
// Access token — короткоживущий
accessClaims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "question-service",
Subject: strconv.FormatInt(userID, 10),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(s.accessSecret)
if err != nil {
return nil, err
}
// Refresh token — долгоживущий, случайная строка (не JWT)
refreshTokenBytes := make([]byte, 32)
if _, err := rand.Read(refreshTokenBytes); err != nil {
return nil, err
}
refreshToken := base64.RawURLEncoding.EncodeToString(refreshTokenBytes)
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshToken,
ExpiresIn: int(s.accessTTL.Seconds()),
}, nil
}
func (s *TokenService) ValidateAccessToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Проверяем алгоритм подписи — защита от подмены
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.accessSecret, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token claims")
}
3. Где хранятся токены
Access token — в httpOnly cookie. Это защищает от XSS-атак, так как JavaScript не может прочитать такой cookie.
func SetAuthCookies(w http.ResponseWriter, tokenPair *TokenPair) {
// Access token — httpOnly, недоступен из JavaScript
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: tokenPair.AccessToken,
Path: "/",
HttpOnly: true, // Защита от XSS
Secure: true, // Только через HTTPS
SameSite: http.SameSiteStrictMode, // Защита от CSRF
MaxAge: tokenPair.ExpiresIn,
})
// Refresh token — отдельный cookie с уведенным временем жизни
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokenPair.RefreshToken,
Path: "/api/auth/refresh", // Доступен только для refresh endpoint
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600, // 7 дней
})
}
Refresh token — также в httpOnly cookie, но с ограниченным path (/api/auth/refresh), чтобы он отправлялся только при обновлении токена.
4. Обновление токенов (Refresh flow)
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
// 1. Проверяем, что refresh token существует в БД и не отозван
storedToken, err := s.tokenService.GetRefreshToken(ctx, refreshToken)
if err != nil {
return nil, fmt.Errorf("invalid refresh token")
}
// 2. Проверяем срок действия
if storedToken.ExpiresAt.Before(time.Now()) {
s.tokenService.RevokeRefreshToken(ctx, refreshToken)
return nil, fmt.Errorf("refresh token expired")
}
// 3. Проверяем, не был ли токен уже использован (rotation detection)
if storedToken.IsUsed {
// Потенциальная кража refresh token — отзываем ВСЕ токены пользователя
s.log.Warn("refresh token reuse detected, revoking all tokens",
"user_id", storedToken.UserID)
s.tokenService.RevokeAllUserTokens(ctx, storedToken.UserID)
return nil, fmt.Errorf("token reuse detected, all sessions revoked")
}
// 4. Получаем данные пользователя
user, err := s.userRepo.GetByID(ctx, storedToken.UserID)
if err != nil {
return nil, err
}
// 5. Генерируем новую пару токенов (token rotation)
newTokenPair, err := s.tokenService.GenerateTokenPair(user.ID, user.Email)
if err != nil {
return nil, err
}
// 6. Атомарно: помечаем старый как использованный, сохраняем новый
if err := s.tokenService.RotateRefreshToken(ctx, refreshToken, newTokenPair.RefreshToken, user.ID); err != nil {
return nil, err
}
return newTokenPair, nil
}
5. Отзыв токенов (Logout)
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
// Отзываем refresh token
if err := s.tokenService.RevokeRefreshToken(ctx, refreshToken); err != nil {
return err
}
// Access token истечёт сам (короткое время жизни)
// Для немедленного отзыва можно добавить его в blacklist в Redis
return nil
}
func ClearAuthCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
MaxAge: -1, // Удаляем cookie
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/api/auth/refresh",
HttpOnly: true,
Secure: true,
MaxAge: -1,
})
}
6. Middleware для проверки авторизации
func AuthMiddleware(tokenService *TokenService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Читаем access token из cookie
cookie, err := r.Cookie("access_token")
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := tokenService.ValidateAccessToken(cookie.Value)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Добавляем информацию о пользователе в контекст
ctx := context.WithValue(r.Context(), "userID", claims.UserID)
ctx = context.WithValue(ctx, "userEmail", claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Использование в обработчиках
func (s *QuestionService) CreateQuestion(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(int64)
// Создаём вопрос от имени авторизованного пользователя
}
7. Хранение refresh token в БД
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL, -- храним хеш, а не сам токен
is_used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
user_agent TEXT, -- для отслеживания устройств
ip_address INET
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
Почему храним хеш токена, а не сам токен: При утечке БД злоумышленник не сможет использовать хеши для получения новых access token'ов.
Схема потока аутентификации:
1. Login: Client → POST /auth/login → Server → Set-Cookie(access) + Set-Cookie(refresh)
2. Request: Client → Cookie(access) → Server → Validate JWT → Response
3. Expired: Client → Cookie(refresh) → POST /auth/refresh → Server → New Cookie pair
4. Logout: Client → POST /auth/logout → Server → Revoke refresh + Clear cookies
Итого: Access token (JWT, 15 мин) и refresh token (случайная строка, 7 дней) хранятся в httpOnly Secure cookies. Refresh token сохраняется в БД в виде хеша для возможности отзыва. При каждом обновлении старая пара заменяется новой (token rotation). При обнаружении повторного использования refresh token — все сессии пользователя немедленно отзываются.
