Основы аутентификации и авторизации

Безопасность API - это критически важный аспект современной разработки. В этом уроке мы разберём основные концепции и методы защиты API.

Аутентификация vs Авторизация

Аутентификация - это процесс проверки, кто вы такой. Авторизация - это процесс проверки, что вам можно делать.

Пример из жизни:

  • Когда вы показываете паспорт на входе в офис - это аутентификация
  • Когда охранник проверяет, есть ли вы в списке посетителей - это авторизация

В контексте API:

# Аутентификация: кто делает запрос?
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# Авторизация: может ли этот пользователь удалять посты?
DELETE /api/posts/123

Как хранят пароли (и почему их нельзя вернуть)

Прежде чем разбирать методы, закроем вопрос, с которого начинается любая аутентификация: что лежит в базе на месте пароля.

Не пароль.

Представь мясорубку. Закинул мясо, покрутил - получил фарш. Из фарша обратно кусок мяса не собрать: операция односторонняя. Причём одинаковое мясо всегда даёт одинаковый фарш, и это как раз то, что нужно.

Так работает хеш-функция. Пароль пропускают через неё и в базу пишут результат. При входе пароль пропускают снова и сравнивают два фарша. Совпало - пустили.

Отсюда практический вывод, который стоит запомнить и вне разработки: нормальный сервис не знает пароль пользователя. Если на «забыли пароль» приходит письмо со старым паролем - сервис умеет его восстанавливать, то есть хранит либо открытым текстом, либо обратимо зашифрованным. Второе приличнее первого, но для парольной аутентификации не годится тоже: ключ расшифровки лежит в том же сервисе, и утечка базы обычно означает утечку ключа.

Хеширование, шифрование и кодирование - три разные операции, и их постоянно путают. Кодирование (base64, URL-encoding) меняет форму записи и разворачивается обратно без всяких секретов. Шифрование разворачивается обратно при наличии ключа. Хеширование не разворачивается вовсе: обратной функции не существует.

Поэтому «расшифровать хеш» - выражение бессмысленное. Украденную базу хешей не расшифровывают, а перебирают: берут кандидата, хешируют его и сравнивают результат. Ровно на удорожание этого перебора и работают приёмы ниже.

Почему обычного хеша мало

Односторонность - не единственное требование. SHA-256 тоже односторонний, но для паролей не годится: он быстрый. Он и создавался быстрым - для контрольных сумм и подписей это достоинство. Современная видеокарта считает миллиарды таких хешей в секунду, поэтому слабый пароль оказывается среди первых кандидатов перебора. Сколько именно займёт перебор конкретного пароля, зависит от его энтропии, алгоритма, параметров и железа - от миллисекунд до практически недостижимого времени.

Плюс беда с одинаковостью. Два человека выбрали qwerty123 - в базе две одинаковые строки. Взломщик подбирает один раз, а входит в два аккаунта. И готовые таблицы «хеш → пароль» для популярных паролей давно посчитаны за него.

Обе проблемы решают два приёма:

  • Соль - к каждому паролю дописывается случайная строка, своя у каждого пользователя. Одинаковые пароли дают разные хеши, готовые таблицы становятся бесполезны.
  • Рабочий фактор - функцию нарочно делают медленной и настраивают, насколько. Одна проверка при входе - десятки миллисекунд, человек не заметит. Перебор миллиарда вариантов при этом становится непозволительно дорогим.

Функции, которые так устроены: bcrypt, scrypt, argon2. Это не обычные хеш-функции, а схемы хеширования паролей (их же называют KDF - key derivation function): медленность и настраиваемая стоимость заложены в них по проекту. Соль они генерируют сами и хранят внутри итоговой строки, поэтому отдельного поля в базе под неё не нужно.

Разделение, которое стоит держать в голове при разговоре с пользователями: длина пароля защищает от перебора, а уникальность - от повторного использования украденных пар логин-пароль (credential stuffing). Это разные угрозы, и уникальный пароль не делает конкретный хеш труднее перебрать.

import "golang.org/x/crypto/bcrypt"

// Регистрация: соль bcrypt сгенерирует сам и положит внутрь результата.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
    return err
}
// hash это []byte вида $2a$10$N9qo8uLOickgx2ZMRZoMye... - в базу идёт он

// Вход: сравнивает за постоянное время, а соль достаёт из самого хеша.
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
    return errors.New("неверный логин или пароль")
}

DefaultCost равен 10. Число задаёт рабочий фактор: каждая единица - удвоение времени проверки. Значение подбирают под своё железо так, чтобы одна проверка занимала десятки миллисекунд.

У bcrypt есть предел: он использует только первые 72 байта пароля. Для длинных парольных фраз это значит, что хвост не участвует в проверке.

<?php
declare(strict_types=1);

// Регистрация: соль и алгоритм PHP выбирает сам.
$hash = password_hash($password, PASSWORD_DEFAULT);

// Вход
if (!password_verify($password, $hash)) {
    throw new RuntimeException('Неверный логин или пароль');
}

// Алгоритм по умолчанию со временем меняется. Эта проверка позволяет
// молча пересчитать хеш на новый при следующем успешном входе.
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($password, PASSWORD_DEFAULT);
    // сохранить $newHash
}

password_hash без соли не бывает - она генерируется и кладётся в строку результата. Собирать md5($password . $salt) руками не нужно и не стоит.

Что это значит для API

  • Восстановления пароля не существует, есть только сброс: одноразовая ссылка на почту, по ней задаётся новый.
  • Ответ на неверный вход не должен выдавать, что именно не подошло. «Неверный логин или пароль» вместо «такого пользователя нет» - иначе форма входа превращается в способ узнать, зарегистрирован ли адрес.
  • Проверка пароля медленная по замыслу, поэтому она же становится удобной мишенью для перебора. Ограничение частоты запросов из раздела Rate Limiting ниже здесь обязательно.

Дальше пароль в запросах не участвует: вместо него ходит токен. Как это устроено на практике - в уроках про сессии и cookie в PHP и JWT в Python, а целиком с кодом - в проекте TODO API.

Методы аутентификации

1. Basic Authentication

Самый простой метод - передача логина и пароля в каждом запросе.

# Логин:пароль кодируются в Base64
curl -H "Authorization: Basic YWxpY2U6cGFzc3dvcmQxMjM=" \
  https://api.example.com/users

Плюсы:

  • Простота реализации
  • Поддерживается везде

Минусы:

  • Небезопасно без HTTPS
  • Нужно хранить пароль на клиенте
  • Нет возможности отозвать доступ

2. API Keys

Клиент получает уникальный ключ для доступа к API.

# В заголовке
curl -H "X-API-Key: abc123def456" \
  https://api.example.com/users

# В query параметре - так делать не надо, см. ниже
curl https://api.example.com/users?api_key=abc123def456

Плюсы:

  • Простота использования
  • Можно ограничить права ключа
  • Легко отозвать

Минусы:

  • Ключ = полный доступ
  • Сложно ограничить по времени
  • Нужно безопасно хранить
  • В query-параметре ключ утекает: попадает в access-логи сервера и прокси, в историю браузера и в заголовок Referer при переходе по внешней ссылке. Передавать только заголовком - Authorization или свой X-API-Key

3. Bearer Tokens

Современный подход с использованием токенов.

# Получаем токен
curl -X POST https://api.example.com/auth/login \
 -d '{"email": "user@example.com", "password": "secret"}'

# Используем токен
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  https://api.example.com/users

Плюсы:

  • Можно задать время жизни
  • Не передаём пароль в каждом запросе
  • Поддержка refresh токенов

Минусы:

  • Нужно обновлять токены
  • Требует хранилища токенов

JWT (JSON Web Tokens)

JWT - это стандарт для создания токенов доступа.

Главный аргумент за JWT звучит как «аутентификация становится stateless», и это правда ровно про один шаг: **самодостаточный access-токен сервер проверяет подписью, без обращения к хранилищу сессий**. Отсюда и выигрыш - проверка не ходит в базу на каждый запрос.

Дальше формулировку обычно расширяют до всей системы, и вот это уже неверно. Как только появляются вещи, которые нужны любому реальному продукту:

  • ротация refresh-токенов и обнаружение их повторного использования;
  • отзыв доступа до истечения срока;
  • список устройств и «выйти на всех»;
  • реакция на компрометацию,

серверное состояние возвращается - refresh-токены в базе, denylist, версия токена. Разница в том, где это состояние: не на горячем пути каждого запроса, а на редких операциях обновления и отзыва.

Точная формулировка: проверять самодостаточный access-токен можно без состояния; управление сессиями и отзывом состояния требует. Ниже в этом уроке - что именно происходит при /logout и чем платят за мгновенный отзыв.

Структура JWT

JWT состоит из трёх частей, разделённых точками:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. Header - тип токена и алгоритм
{
  "alg": "HS256",
  "typ": "JWT"
}
  1. Payload - данные
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}
  1. Signature - подпись для проверки

Пример работы с JWT

// Node.js с jsonwebtoken
const jwt = require('jsonwebtoken');

// Создание токена
const payload = {
  userId: 123,
  email: 'user@example.com',
  role: 'user'
};

const token = jwt.sign(payload, 'secret-key', {
  expiresIn: '1h'
});

// Проверка токена
try {
  const decoded = jwt.verify(token, 'secret-key');
  console.log(decoded);
} catch (err) {
  console.error('Invalid token');
}

JWT Best Practices

  1. Используйте короткое время жизни
const token = jwt.sign(payload, secret, {
  expiresIn: '15m' // 15 минут
});
  1. Не храните sensitive данные в payload
// Плохо
const payload = {
  userId: 123,
  password: 'secret123',  // Никогда!
  creditCard: '4111111111111111'  // Никогда!
};

// Хорошо
const payload = {
  userId: 123,
  role: 'user',
  permissions: ['read', 'write']
};
  1. Используйте refresh токены
// Access token - короткоживущий
const accessToken = jwt.sign(payload, secret, {
  expiresIn: '15m'
});

// Refresh token - долгоживущий
const refreshToken = jwt.sign(
  { userId: payload.userId },
  refreshSecret,
  { expiresIn: '30d' }
);

Схема выше - минимальный каркас, и в этом виде у неё есть дыра: refresh token живёт 30 дней и не отзывается. Украли его - доступ у злоумышленника тоже на 30 дней, и «выйти со всех устройств» сделать нечем, потому что сервер о выданных токенах ничего не знает.

Поэтому refresh обычно не делают самодостаточным JWT:

  • хранят на сервере (таблица или Redis) - идентификатор сессии, устройство, дата выдачи. Тогда отзыв - это DELETE, а не ожидание истечения;
  • ротируют при каждом использовании: обмен refresh на новую пару инвалидирует старый. Повторное предъявление уже использованного refresh - сигнал кражи, и правильная реакция на него - погасить всю цепочку сессии;
  • привязывают к устройству (User-Agent, отпечаток клиента), чтобы токен, утёкший на другую машину, не подошёл.

Access token при этом остаётся stateless JWT - в этом и смысл пары: проверка на каждом запросе идёт без обращения к базе, а в базу мы ходим только при обновлении, то есть раз в 15-30 минут.

Самая частая ошибка в реализации выхода - считать, что endpoint `/logout` делает уже выданный access token недействительным. Со stateless JWT это не так и не может быть так: сервер его не хранит, а подпись остаётся валидной до `exp`.

Что происходит на самом деле:

POST /logout
  → refresh token отозван (удалён из базы)
  → новую пару получить нельзя
  → выданный access token продолжает работать до exp (15-30 минут)

Для большинства приложений это приемлемо - именно поэтому access делают короткоживущим. Но если требуется немедленный отзыв (сотрудника уволили, обнаружена компрометация), stateless-проверки недостаточно, и нужно серверное состояние. Варианты:

СпособКак работаетЦена
denylistсписок отозванных jti в Redis до истечения их expзапрос в Redis на каждый запрос API
версия токенав payload token_version, при отзыве версия в базе растётзапрос в базу или кеш на каждый запрос
opaque sessionвместо JWT - идентификатор сессии, состояние на серверевернулись к сессиям, зато отзыв мгновенный

Все три означают одно и то же: за мгновенный отзыв платят тем, ради чего JWT и брали, - проверкой без обращения к хранилищу. Это нормальный выбор, но делать его надо осознанно, а не обнаруживать после инцидента.

OAuth 2.0

OAuth 2.0 - это протокол авторизации, позволяющий приложениям получать ограниченный доступ к ресурсам пользователя.

Роли в OAuth 2.0

  1. Resource Owner - пользователь
  2. Client - приложение, запрашивающее доступ
  3. Authorization Server - сервер, выдающий токены
  4. Resource Server - API с защищёнными ресурсами

Authorization Code Flow

Самый безопасный flow для веб-приложений:

1. Пользователь нажимает "Войти через Google"
   → Redirect to: https://accounts.google.com/oauth/authorize?
     client_id=abc123&
     redirect_uri=https://myapp.com/callback&
     response_type=code&
     scope=email%20profile

2. Пользователь разрешает доступ
   → Google redirects to: https://myapp.com/callback?code=xyz789

3. Приложение обменивает код на токен
   POST https://oauth2.googleapis.com/token
   {
     "code": "xyz789",
     "client_id": "abc123",
     "client_secret": "secret456",
     "grant_type": "authorization_code"
   }

4. Получаем access token
   {
     "access_token": "ya29.a0AfH6SMBx...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "refresh_token": "1//0gFu3..."
   }

Scopes (области доступа)

Scopes ограничивают, к чему токен даёт доступ:

// Запрашиваем только email и профиль
const authUrl = `https://accounts.google.com/oauth/authorize?
  client_id=${clientId}&
  scope=email%20profile&
  response_type=code`;

// Токен будет иметь доступ только к email и profile API

Безопасность API

1. Всегда используйте HTTPS

server {
    listen 443 ssl;
    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # Redirect HTTP to HTTPS
    if ($scheme != "https") {
        return 301 https://$server_name$request_uri;
    }
}

2. Rate Limiting

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 минут
  max: 100, // максимум 100 запросов
  message: 'Too many requests'
});

app.use('/api/', limiter);

3. CORS (Cross-Origin Resource Sharing)

const cors = require('cors');

app.use(cors({
  origin: 'https://trusted-domain.com',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

4. Валидация входных данных

const { body, validationResult } = require('express-validator');

app.post('/api/users',
  body('email').isEmail(),
  body('password').isLength({ min: 8 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // Обработка валидных данных
  }
);

5. Безопасные заголовки

const helmet = require('helmet');
app.use(helmet());

// Или вручную
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  next();
});

Хранение токенов на клиенте

В браузере - storage в JS

// localStorage - переживает перезагрузку, читается любым скриптом на странице
localStorage.setItem('token', accessToken);

// sessionStorage - тот же доступ из JS, разница только в времени жизни:
// чистится при закрытии вкладки. Против XSS это не защита.
sessionStorage.setItem('token', accessToken);

// HttpOnly cookie - недостижима для JS, ставится сервером:
res.cookie('token', accessToken, {
  httpOnly: true,      // JS не читает - XSS не выносит токен напрямую
  secure: true,        // только по HTTPS
  sameSite: 'strict',  // не уходит с межсайтовых запросов - защита от CSRF
  maxAge: 3600000,     // 1 час
});
Каждый пункт сильнее в одном и слабее в другом. `HttpOnly` cookie закрывает кражу токена через XSS, но открывает CSRF - его придётся закрывать `SameSite`, а для межсайтовых сценариев ещё и CSRF-токеном. `localStorage` от CSRF не страдает вовсе, зато сдаёт токен первому же выполненному на странице скрипту.

Что не зависит от модели угроз: токен не место в параметрах URL (логи, история браузера, заголовок Referer) и не константа в коде фронтенда. Остальное - осознанный компромисс; для SPA обычно берут access token в памяти плюс refresh в HttpOnly cookie (подробнее с кодом).

В мобильных приложениях

// iOS - Keychain
let keychain = Keychain(service: "com.myapp")
keychain["access_token"] = token

// Android - SharedPreferences (encrypted)
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val prefs = EncryptedSharedPreferences.create(
    context,
    "secret_shared_prefs",
    masterKey,
    // ...
)
prefs.edit().putString("token", accessToken).apply()

Итоги

Безопасность API - это комплексная задача, требующая:

  • Правильного выбора метода аутентификации
  • Безопасного хранения и передачи токенов
  • Защиты от распространённых атак
  • Регулярного обновления и мониторинга

В следующем уроке мы изучим принципы проектирования REST API и лучшие практики создания понятных и удобных интерфейсов. Безопасность URL-параметров - в уроке web/url-security.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.