Безопасная аутентификация: хеширование, JWT, OAuth2 и 2FA

Безопасная аутентификация: хеширование, JWT, OAuth2 и 2FA

Аутентификация — это проверка «ты тот, за кого себя выдаёшь». Её ломают не одним трюком, а цепочкой ошибок: пароли в открытом виде, JWT без проверки alg, OAuth без state, второй фактор по SMS. Разберём четыре столпа и где на каждом наступает реальный продакшн.

Статья практическая: PHP + WordPress, с готовыми сниппетами. Про авторизацию (current_user_can, RBAC) — отдельная тема, здесь только проверка личности.


Часть I. Хеширование паролей

1. Почему не «просто SHA-256»

Хеш пароля должен быть медленным и памятеёмким, иначе GPU-перебор сметает базу за часы.

МетодВердиктПочему
md5(), sha1()⛔мгновенный перебор, радужные таблицы
sha256($pass) без соли⛔один и тот же пароль у всех даёт один хеш, перебор по GPU
sha256($pass . $salt)⛔всё ещё быстрый, перебор оптимален на GPU/ASIC
bcrypt / scrypt / Argon2id✅медленный по дизайну, память (Argon2/scrypt) бьёт по ASIC

Отдельная ошибка — «секретная соль как пароль». Соль не обязана быть тайной; её задача — uniqueness (один хеш на один пароль у одного пользователя), чтобы перебор одного украденного хеша не раскрывал совпадающие пароли у других. PHP кладёт соль внутрь хеш-строки сам.

2. Правильно в PHP: password_hash / password_verify

PHP
<?php
declare(strict_types=1);

// Регистрация: сохраняем ТОЛЬКО этот хеш.
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Если PHP собран без libargon2 — PASSWORD_ARGON2ID бросит ошибку.
// Безопасный выбор с фолбэком:
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
$hash = password_hash($password, $algo);

save_password_hash($userId, $hash);

PHP
<?php
// Вход: сравниваем через password_verify, НЕ через ==.
$stored = fetch_password_hash($userId);   // может быть null: user не найден
if ($stored === null) {
    // Имитируем работу, чтобы по времени не различать "нет такого логина".
    password_verify($password, '$2y$10$'.str_repeat('.', 53));
    auth_fail_generic();
} elseif (password_verify($password, $stored)) {
    // ...успех...
} else {
    auth_fail_generic();
}

Важные детали:

  • password_verify принимает хеш и сравнивает корректно (тайминг-безопасно для хеша). Самостоятельное == $hash — ошибка.
  • Не обрезайте и не нормализуйте пароль перед хешированием без причины (нижний регистр ломает entropy-политику; trim по пробелам — спорно). Лимит PHP на длину в password_hash — до 4096 байт; у bcrypt реальный предел пароля — 72 байта (всё, что длиннее, молча отбрасывается). Если нужен пароль длиннее 72 байт — берите Argon2id.
  • Хеш бинарен/длинный: сохраняйте поле как VARCHAR(255) минимум (Argon2-строка — ~97+ символов). Не VARCHAR(60) (это под старый phpass/bcrypt 60).

3. Миграция старых хешей

Если у вас исторически md5/sha1 или bcrypt с низким cost — не просите всех сменить пароль. Перехешируйте при входе:

PHP
<?php
if (password_verify($password, $stored)) {
    // Успешный вход. Проверяем, актуален ли алгоритм/параметры.
    $rehashOpts = [
        'memory_cost' => 19456, // ~19 MiB — ориентир OWASP Password Storage Cheat Sheet (Argon2id)
        'time_cost'   => 2,
        'threads'     => 1,
    ];
    if (password_needs_rehash($stored, PASSWORD_ARGON2ID, $rehashOpts)) {
        $new = password_hash($password, PASSWORD_ARGON2ID, $rehashOpts);
        save_password_hash($userId, $new);   // поднимаем пароль на свежий хеш
    }
    start_session($userId);
}

Точные пороги memory_cost/time_cost сверяйте с актуальной OWASP Password Storage Cheat Sheet на момент деплоя — OWASP пересматривает минимумы по мере удешевления GPU. Приведённые 19456 KiB / t=2 / p=1 — ориентир для Argon2id из этой шпаргалки; это нижняя граница, не потолок: выше по памяти/итерациям — дольше перебор.

4. Ошибки на входе и восстановлении

  • User enumeration: одинаковые сообщения и статус для «нет такого пользователя» и «неверный пароль». Иначе сканер собирает список существующих логинов (для WordPress это отдельная боль — wp-login и ?author=N).
  • Rate limit на попытку входа (per-IP + per-account), иначе slow brute force не ловится.
  • Логирование: пишите события входа, но никогда не пишите пароль/хеш в логи.
  • Reset-токен для смены пароля — одноразовый, короткоживущий (random_bytes), привязан к юзеру, удаляется после использования; не «hash(email+time)».

Часть II. JWT: как не сломать подпись

5. Что такое JWT и чем он «не является»

JWT — это подписанная, но не зашифрованная структура header.payload.signature в base64url. Payload читается кем угодно (base64 декодируется в одну строку). Секреты/PII в payload класть нельзя. JWT не равен сессии: сервер по умолчанию ничего не хранит и не может «отозвать» выданный токен, пока не истёк срок.

6. Атаки, которые реально находят

6.1 alg: none

В заголовке разрешают алгоритм none — подпись не проверяется вовсе:

{"alg":"none","typ":"JWT"}   // подпись пустая
{"sub":"admin","role":"admin"}

Серьёзные библиотеки это уже не принимают, но самописная проверка на PHP — классический источник дыры. Лечится: жёсткий allowlist алгоритмов на сервере, а не «разбираем alg из заголовка и по нему решаем».

6.2 Подмена алгоритма (RS256 → HS256)

Если публичный ключ сервера известен (/jwks), атакующий делает заголовок HS256 и подписывает HMAC-подпись самим публичным ключом как секретом. Библиотека, которая «доверяет alg из токена», проверит HMAC этим же публичным значением и пропустит.

Лечится: сервер жёстко фиксирует ожидаемый алгоритм (RS256 — и только RS256), никогда не берёт alg из заголовка как директиву.

6.3 Прочее

  • kid (Key ID) без фильтра → SQLi/путь к файлу, если библиотека подставляет kid в запрос за ключом.
  • Проверять exp/nbf/iss/aud, а не только подпись. Токен от другого audience не должен приниматься.
  • iat для refresh-логики.

7. Минимально правильная проверка на PHP

PHP
<?php
function jwt_verify(string $jwt, string $publicKeyPem): array
{
    $parts = explode('.', $jwt);
    if (count($parts) !== 3) {
        throw new JwtException('malformed');
    }
    [$h64, $p64, $s64] = $parts;

    $header = json_decode(b64url_decode($h64), true, 512, JSON_THROW_ON_ERROR);
    // 1) Алгоритм — ТОЛЬКО ожидаемый. Игнорируем всё, что заявил токен.
    if (($header['alg'] ?? '') !== 'RS256') {
        throw new JwtException('alg not allowed');
    }

    // 2) Проверяем подпись RSASSA-PKCS1-v1_5 / SHA-256 по публичному ключу.
    $signingInput = $h64 . '.' . $p64;
    $sig = b64url_decode($s64);
    $ok = openssl_verify($signingInput, $sig, $publicKeyPem, OPENSSL_ALGO_SHA256);
    if ($ok !== 1) {
        throw new JwtException('bad signature');
    }

    $claims = json_decode(b64url_decode($p64), true, 512, JSON_THROW_ON_ERROR);

    // 3) Временные/структурные клеймы.
    $now = time();
    if (($claims['exp'] ?? 0) <= $now) throw new JwtException('expired');
    if (isset($claims['nbf']) && $claims['nbf'] > $now) throw new JwtException('not yet');
    if (($claims['iss'] ?? '') !== 'https://auth.example.com') throw new JwtException('bad iss');
    if (($claims['aud'] ?? '') !== 'example-api') throw new JwtException('bad aud');

    return $claims;
}

function b64url_decode(string $s): string
{
    return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true)
        ?: throw new JwtException('bad base64url');
}

Практический совет: не пишите свой verify в проде — используйте проверенную библиотеку (web-token/jwt-framework, firebase/php-jwt), и зафиксируйте допустимые алгоритмы в её конфиге (array('RS256') вместо «принять любой из header»). Сниппет выше — чтобы видеть, что именно должно проверяться.

8. Отзыв, TTL и refresh

Поскольку JWT не хранится сервером, «разлогинить/запретить» его штатно нельзя. Варианты:

  • Короткий TTL access-токена (5–15 мин) + refresh-токен отдельно. Отзыв = не продлеваем refresh.
  • jti + блок-лист (Redis/Кука-отзыв): на каждый запрос сверяем jti с множеством отозванных; держим блок-лист до истечения exp. Это reintroduces state, но делает JWT отзываемым.
  • Смена секрета подписи инвалидирует все токены (тяжёлый способ, для экстренных случаев).

Refresh-токен держите дольше, но: ротация refresh на каждое продление (reuse detection), привязка к устройству/пользователю, хранение только хеша refresh.

9. Где хранить на клиенте

  • localStorage — читается любым XSS (см. предыдущую статью). Хранить JWT там = отдать токен XSS’у.
  • Лучше: HttpOnly-кука для access/refresh + защита CSRF (т.к. кука уходит автоматически), либо BFF-паттерн (токены живут на вашем бэке, браузер получает только сессионную куку).
  • Если JWT нужен именно в заголовке Authorization (SPA без бэкенда-прокси) — минимизируйте TTL и жёстко закрывайте XSS.

Часть III. OAuth 2.0 + OIDC

10. Карта ролей и потоков

  • Resource Owner — пользователь. Client — ваше приложение. Authorization Server — выдаёт код/токены. Resource Server — API, принимающий access-токен.
  • Authorization Code flow — основной и рекомендуемый: пользователь идёт на authorize, получает code на redirect_uri, потом code обменивается на токены на сервере.
  • ⛔ Implicit flow (токен прямо в URL-фрагменте) — deprecated OAuth 2.1: токен светится в истории браузера, не требует PKCE корректно. Не используйте.
  • Client Credentials — для server-to-server (машина ↔ машина), без участия пользователя. Секрет клиента тут обязателен.
  • Authorization Code + PKCE — для PWA/SPA и мобильных, где секрет клиента нельзя спрятать.

11. PKCE: почему «даже для confidential»

Authorization Code перехватывается, если злоумышленник видит code на redirect_uri. PKCE (RFC 7636) привязывает обмен code → token к секрету, известному только реальному клиенту:

client делает:
  code_verifier = random(64+ байт, base64url)        // остаётся у клиента
  code_challenge = BASE64URL(SHA256(code_verifier))  // летит в authorize
  ... получаем code ...
  token request: code + code_verifier               // сервер проверяет SHA256(verifier)==challenge
PHP
<?php
// Генерация verifier (PKCE) на клиентской стороне/бэке
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
// В authorize: &code_challenge={$challenge}&code_challenge_method=S256
// Сохранить $verifier в сессии до обмена.
  • method — только S256 (SHA-256). Метод plain уязвим, если verifier попадает в URL/логи.
  • RFC 7636 рекомендует verifier длиной 43–128 символов — я использую 48 байт → 64 символа, в норме.
  • В OAuth 2.1 PKCE требуется для публичных клиентов и настоятельно для всех.

12. CSRF и redirect_uri: два столба

  • state — случайное значение из сессии клиента, возвращается на redirect_uri и сверяется на входе. Без state злоумышленник подсовывает свою ссылку обратного вызова и «прицепляет» ваш аккаунт к своему (login CSRF / account linking).
  • redirect_uri — точное сравнение с зарегистрированным значением. Разрешать поддомены/«начинается с» / префиксные совпадения = открытая дверь к утечке code. Проверка str_starts_with или wildcard — классическая дыра.
PHP
<?php
// На возврате: сверяем state ДО обмена code.
$sess = $_SESSION['oauth_state'] ?? '';
if (!isset($_GET['state']) || !is_string($_GET['state'])
    || $sess === '' || !hash_equals($sess, $_GET['state'])) {
    abort('state mismatch');
}
unset($_SESSION['oauth_state']); // одноразово

13. Хранение токенов и scope

  • Не запрашивайте лишние scope (openid profile email вместо email) — principle of least scope.
  • Access-токен от чужого провайдера не отдавайте фронтенду «как есть», если можно держать его на бэке.
  • Логаут: для OIDC используйте RP-initiated logout + отзыв refresh на своей стороне; чистая кука-сессия не отзывает токен у провайдера.

14. OIDC поверх OAuth2

OAuth2 сам по себе — про авторизацию (доступ к ресурсам), про аутентификацию — надстройка OIDC: слой id_token (JWT с sub, iss, aud, auth_time, nonce) поверх Authorization Code. Проверки id_token: подпись (allowlist alg), iss, aud (свой client_id), exp, nonce (если использовался) — ровно как в разделе JWT. Не путайте «получил access_token» с «проаутентифицировал пользователя» — для этого нужен валидный id_token.


Часть IV. Двухфакторная аутентификация (2FA/MFA)

15. Три типа факторов и почему SMS — «так себе»

  • Знание (пароль). Владение (ключ FIDO2, телефон с TOTP-приложением). Неотъемлемость (биометрия).
  • SMS-код формально «владение номером», но номер угоняют через SIM-swap (соци-инженерия оператора) и SS7-атаки. NIST и площадки безопасности не считают SMS сильным фактором. Годится как «лучше, чем ничего», но не для высоких рисков.

Порядок устойчивости: WebAuthn/FIDO2 (устойчив к фишингу по дизайну) > TOTP (устойчив к перебору, но фишинг-сайты перехватывают код) > SMS/Push (слабые).

16. WebAuthn/FIDO2 — предпочтительно

Ключ живит приватный ключ на устройстве, сервер хранит только публичный. Аутентификация привязана к origin (rpId + origin) — токен с фишингового сайта не проходит. В PHP проверка — через библиотеку (webauthn-php, 24wps.net/Attestation-обвязки); в WordPress — существующие плагины MFA с поддержкой WebAuthn.

Плюсы против TOTP: нет секретной «седины» на сервере, нет перехвата по фишингу, нет переноса shared secret.

17. TOTP: что проверять, если делаете сами

TOTP (RFC 6238) = HMAC-SHA1 от текущего окна времени по общему секрету, 6 цифр.

PHP
<?php
// Секрет генерируется ОДИН раз при подключении 2FA, хранится защищённо
// (в идеале — в encrypted store, не в открытом usermeta).
$secret = Base32::encode(random_bytes(20));

// Проверка: окно ±1 (допускаем дрейф часов), счётчик попыток, одноразовость.
function totp_verify(string $secret, string $code, int $window = 1): bool {
    $now = intdiv(time(), 30);
    for ($i = -$window; $i <= $window; $i++) {
        if (hash_equals(totp_at($secret, $now + $i), $code)) {
            return true;
        }
    }
    return false;
}

Что обязательно вокруг TOTP:

  • Rate limit на сам ввод кода (3–5 попыток), иначе 6-значный код перебираем.
  • Одноразовость: уже использованный код в текущем окне не принимается повторно (храните last-used step).
  • Защита от фишинга: TOTP.pass-код пользователь отдаёт любому сайту, который его попросит — поэтому TOTP + честная сессия лучше, но WebAuthn закрывает именно фишинг.

18. Backup-коды и «выключить 2FA»

  • Backup-коды генерируются как случайные строки, хранятся хешем (password_hash), каждый одноразовый (пометить использованный).
  • Самая частая дыра — путь «я потерял 2FA, сбросьте его» через почту/поддержку: атакующий с угнанным имейлом отключает второй фактор. Требуйте период ожидания, подтверждение через другой канал, ручной аудит для чувствительных аккаунтов.
  • 2FA включается в несколько шагов: отсканировал → ввёл код с устройства (подтверждение владения) → только после этого считать 2FA активной. Иначе «включил», сохранив секрет, но без проверки — иллюзия защиты.

Часть V. Специфика WordPress

19. Как WP хранит и проверяет пароль

WordPress не использует голый password_hash напрямую для пользователей: хеш формируется через phpass/bcrypt внутри wp_hash_password(), а проверяется через wp_check_password() (последняя умеет распознавать и старые форматы phpass, и современные bcrypt).

PHP
<?php
// Никогда не сравнивайте хеши сами — только wp_check_password.
$plain = $inputPassword;
$stored = get_user_meta($userId, '...') ?: $user->user_pass; // на деле берём из БД

if (wp_check_password($plain, $stored, $userId)) {
    // успех
    wp_set_auth_cookie($userId, $remember, is_ssl());
}

Ключевые правила:

  • wp_hash_password() никогда не вызывать для проверки — это односторонняя функция хеширования; для сверки существует wp_check_password($password, $hash, $user_id). Третий аргумент участвует в миграции старых хешей.
  • Смена пароля меняет хеш и сбрасывает сессионные токены (WP_Session_Tokens) — существующие «запомненные» куки на других устройствах инвалидируются штатно. При ручных сбросах через прямой апдейт user_pass (минуя wp_update_user) — вызовите WP_Session_Tokens::get_instance($user_id)->destroy_all() сами.
  • remember-me кука живёт 14 дней — для sensitive-аккаунтов отключайте remember.

20. Application Passwords и JWT в WP

  • Application Passwords (ядро с 5.6) — правильный способ дать пользователю «секрет» для REST/XML-RPC без его основного пароля. Отображается один раз, хранится хешем, отзывается в профиле.
  • JWT-плагины (jwt-authentication-for-wp-rest-api и т.п.) опасны тем, что часто: ставят длинный TTL, не дают отзыва, а ключ подписи лежит в wp-config/опции. Если используете — фиксируйте alg, короткий TTL, держите секрет вне репозитория, и помните: JWT-плагины в WP не дают «stateless logout».
  • REST по cookie требует X-WP-Nonce (см. статью про CSRF) — JWT-подход его обходит, поэтому жёстко нужен alg-allowlist и срок.

21. Двухфакторка и вход в админку

  • Для 2FA в WordPress — плагины класса WP 2FA / miniOrange / Duo, где есть WebAuthn/TOTP и принудительная политика по ролям; самописная «TOTP в functions.php» часто ломает recovery.
  • Усильте сам вход: rate-limit логина (иначе brute по wp-login.php), отключите/закройте xmlrpc.php (источник brute-force amplify), ?author=1 закройте (enumeration), 404/403 для несуществующих логинов одинаковые.

Чек-лист

Хеширование:

  • password_hash (Argon2id → фолбэк bcrypt), проверка password_verify; никогда ==/md5/sha1/«солёный SHA».
  • Поле VARCHAR(255); про 72-байтовый предел bcrypt помните; длинные пароли — Argon2id.
  • password_needs_rehash при входе для миграции; параметры сверять с OWASP Cheat Sheet на момент деплоя.
  • Одинаковые ответы/тайминги для «нет юзера» vs «неверный пароль», rate-limit, секреты не в логах.

JWT:

  • Алгоритм фиксируется на сервере (allowlist), alg из токена — директивой не является; none и RS256→HS256 отсекаются.
  • Проверяются exp/nbf/iss/aud + подпись (hash_equals для HMAC; openssl_verify для RSA).
  • Короткий TTL + refresh; отзыв через jti-блок-лист или смену секрета. Секретов/PII в payload нет. Хранение — HttpOnly-кука/BFF, не localStorage.

OAuth2 / OIDC:

  • Authorization Code (+ PKCE, только S256), никаких Implicit.
  • state сверяется через hash_equals до обмена code; redirect_uri — точное совпадение.
  • Секрет клиента не в браузере; scope по минимуму; аутентификация = валидный id_token (OIDC), а не факт наличия access-токена.

2FA:

  • Приоритет WebAuthn/FIDO2 > TOTP > SMS (SMS не считать сильной).
  • Rate-limit на код, одноразовость, окно ±1 для TOTP.
  • Backup-коды хешем, одноразовые; путь «сбросить 2FA» закрыт от того, у кого только почта.
  • Включение 2FA подтверждается вводом кода до того, как считать её активной.

WordPress:

  • Проверка пароля только через wp_check_password; смена пароля/destroy_all() инвалидируют сессии.
  • Application Passwords для API-секретов; с JWT-плагинами — фиксированный alg, короткий TTL, секрет вне репо.
  • Rate-limit входа, закрыты xmlrpc/?author=, одинаковые ответы на unknown user.

Источники

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