Основы аутентификации и авторизации
Безопасность 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 - это стандарт для создания токенов доступа.
Дальше формулировку обычно расширяют до всей системы, и вот это уже неверно. Как только появляются вещи, которые нужны любому реальному продукту:
- ротация refresh-токенов и обнаружение их повторного использования;
- отзыв доступа до истечения срока;
- список устройств и «выйти на всех»;
- реакция на компрометацию,
серверное состояние возвращается - refresh-токены в базе, denylist, версия токена. Разница в том, где это состояние: не на горячем пути каждого запроса, а на редких операциях обновления и отзыва.
Точная формулировка: проверять самодостаточный access-токен можно
без состояния; управление сессиями и отзывом состояния требует. Ниже
в этом уроке - что именно происходит при /logout и чем платят
за мгновенный отзыв.
Структура JWT
JWT состоит из трёх частей, разделённых точками:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header - тип токена и алгоритм
{
"alg": "HS256",
"typ": "JWT"
}
- Payload - данные
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
- 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
- Используйте короткое время жизни
const token = jwt.sign(payload, secret, {
expiresIn: '15m' // 15 минут
});
- Не храните sensitive данные в payload
// Плохо
const payload = {
userId: 123,
password: 'secret123', // Никогда!
creditCard: '4111111111111111' // Никогда!
};
// Хорошо
const payload = {
userId: 123,
role: 'user',
permissions: ['read', 'write']
};
- Используйте 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 минут.
Что происходит на самом деле:
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
- Resource Owner - пользователь
- Client - приложение, запрашивающее доступ
- Authorization Server - сервер, выдающий токены
- 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 час
});
Что не зависит от модели угроз: токен не место в параметрах 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.