Безопасность приложений Аутентификация: пароли, хеширование, сессии, MFA, восстановление доступа
0%

Аутентификация: пароли, хеширование, сессии, MFA, восстановление доступа

Аутентификация: пароли, хеширование, сессии, MFA, восстановление доступа

Аутентификация — ответ на вопрос «кто передо мной». Не «что ему можно» (это авторизация, см. Авторизация: RBAC, ABAC, ACL, multi-tenancy) и не «как его зовут» (это идентификация). Аутентификация — проверка заявленной идентичности с приемлемым уровнем уверенности, и слово «приемлемым» здесь ключевое. Абсолютной уверенности не бывает: любой механизм сводится к проверке того, что человек знает, имеет или чем является, а всё это можно украсть, скопировать или подделать. Задача — поднять стоимость подделки выше стоимости актива и сделать неудачную попытку заметной.

Статья написана с позиции защищающейся стороны. Уязвимый код здесь показан, чтобы вы узнали его в своём репозитории и починили. Любые проверки — только на системах, которыми вы владеете, либо при письменном разрешении владельца с зафиксированным объёмом работ. В OWASP Top 10 2021 тема проходит как A07 «Identification and Authentication Failures» (см. OWASP Top 10), базовый CWE — CWE-287.

Три фактора и главный водораздел

Фактор Что это Как теряется Масштаб атаки
Знание пароль, PIN, ответ на вопрос утечка, фишинг, подбор массовая, дистанционная
Владение телефон, ключ FIDO2, смарт-карта кража, перевыпуск SIM, вредонос точечная
Свойство отпечаток, лицо, голос подделка образца, утечка шаблона точечная, необратимая

Смысл многофакторности — в слабой корреляции отказов: кто украл базу паролей, тот не получил телефоны пользователей. Как только корреляция появляется, MFA становится декорацией. Классика — «второй фактор» письмом на ту же почту, куда приходит ссылка восстановления: оба фактора падают от одного взлома ящика.

Но важнее деление не по факторам, а по ретранслируемости секрета. Пароль, код из SMS, шесть цифр из приложения, ссылка из письма — всё это человек может продиктовать или скопировать, значит, посредник передаст их дальше в реальном времени. Passkey и ключ FIDO2 устроены иначе — поэтому их и называют фишинг-устойчивыми.

Модель угроз формы входа

Метод построения — в статье Моделирование угроз; здесь готовый перечень того, от чего защищаемся. Дальше каждый блок разбираем по схеме: уязвимый код → почему это работает → как чинить → как проверить.

Пароли: почему старые политики вредны

Требования «8 символов, заглавная, цифра, спецсимвол, смена каждые 90 дней» пришли из приложения A к NIST SP 800-63 (2003) и отменены не из-за моды, а по результатам измерений. Композиционные правила сдвигают выбор в узкое подмножество (Password1!, Ceh1cok2025!), а ротация порождает предсказуемые серии (Winter2025!Spring2026!). Обе практики уменьшают реальную энтропию и добавляют стикеры на мониторах.

Что зафиксировано в NIST SP 800-63B, раздел 5.1.1:

  • минимум 8 символов для паролей, выбранных человеком; принимать не менее 64;
  • разрешать все печатаемые ASCII, пробел и Unicode; не резать длину и не блокировать вставку из буфера — это ломает менеджеры паролей;
  • никаких композиционных правил и никакой периодической ротации; принудительная смена — только по признаку компрометации;
  • сверять новый пароль со списками утечек, словарями и контекстными значениями (имя сервиса, логин, домен);
  • никаких секретных вопросов — они прямо запрещены как средство восстановления.

Ориентир по энтропии: случайные 12 символов из 95-символьного алфавита — около 79 бит, парольная фраза из 5 случайных слов словаря на 8000 — около 64 бит, «сложный» человеческий пароль на 10 символов — обычно 25–30 бит, потому что распределение выбора далеко от равномерного. Отсюда и вывод: длина плюс проверка по утечкам эффективнее любых правил состава. CWE-521 «Weak Password Requirements».

Хеширование: уязвимый код

# УЯЗВИМО. Встречается в легаси чаще, чем хотелось бы.
import hashlib

def store_password(user_id: str, password: str) -> None:
    digest = hashlib.md5(password.encode()).hexdigest()          # дефекты 1 и 2
    db.execute("UPDATE users SET password_hash=%s WHERE id=%s", (digest, user_id))

def check_password(user_id: str, password: str) -> bool:
    row = db.query_one("SELECT password_hash FROM users WHERE id=%s", (user_id,))
    return row["password_hash"] == hashlib.md5(password.encode()).hexdigest()  # дефект 3

Почему это работает у атакующего. Три независимых дефекта.

  1. MD5 (как и SHA-1, SHA-256) — быстрая функция общего назначения, спроектированная считаться миллиардами раз в секунду. На потребительской видеокарте перебор идёт десятками миллиардов хешей в секунду: офлайн-восстановление паролей из дампа занимает часы. CWE-916 «Insufficient Computational Effort».
  2. Соли нет. Одинаковые пароли дают одинаковые хеши: по дампу сразу видны группы пользователей с общим паролем, а предвычисленные таблицы применимы ко всей базе разом. CWE-759 «One-Way Hash without a Salt».
  3. Сравнение == завершается на первом различии — время зависит от длины совпавшего префикса. Для хешей это редко эксплуатируют по сети, но привычка переносится на токены, где утечка по времени уже реальна. CWE-208 «Observable Timing Discrepancy».

Поскольку люди переиспользуют пароли, последствия такой утечки не ограничиваются вашим сервисом.

Хеширование: как чинить

Нужна функция, специально сделанная медленной и, что важнее, памятеёмкой — чтобы преимущество GPU, FPGA и ASIC над обычным сервером было минимальным. Современный выбор — Argon2id, победитель Password Hashing Competition 2015, RFC 9106.

# ПРАВИЛЬНО. pip install argon2-cffi
from argon2 import PasswordHasher, exceptions as argon_exc
from argon2.low_level import Type
import hmac, hashlib, os, secrets

# Параметры по OWASP Password Storage Cheat Sheet. Цель — 200-500 мс на проверку.
_hasher = PasswordHasher(time_cost=1, memory_cost=47104, parallelism=1,
                         hash_len=32, salt_len=16, type=Type.ID)
_PEPPER = os.environ["PASSWORD_PEPPER"].encode()   # секрет из KMS, не из кода
_DUMMY = _hasher.hash(secrets.token_bytes(32))     # фиктивный хеш той же стоимости

def _prehash(password: str) -> bytes:
    """HMAC до Argon2: добавляет перец и снимает ограничение на длину входа."""
    return hmac.new(_PEPPER, password.encode("utf-8"), hashlib.sha256).digest()

def store_password(user_id: str, password: str) -> None:
    encoded = _hasher.hash(_prehash(password))     # соль генерируется внутри из CSPRNG
    db.execute("UPDATE users SET password_hash=%s WHERE id=%s", (encoded, user_id))

def check_password(user_id: str, password: str) -> bool:
    row = db.query_one("SELECT password_hash FROM users WHERE id=%s", (user_id,))
    encoded = row["password_hash"] if row else _DUMMY   # время ответа не зависит от наличия
    try:
        _hasher.verify(encoded, _prehash(password))
    except (argon_exc.VerifyMismatchError, argon_exc.InvalidHashError):
        return False
    if row and _hasher.check_needs_rehash(encoded):     # параметры устарели — пересчитать
        db.execute("UPDATE users SET password_hash=%s WHERE id=%s",
                   (_hasher.hash(_prehash(password)), user_id))
    return bool(row)

Соль генерируется библиотекой и кладётся внутрь той же строки — вручную её лепить не нужно. Перец через HMAC даёт защиту при утечке только базы: без второго секрета дамп бесполезен для перебора. check_needs_rehash превращает параметры стоимости в версию схемы, и база мигрирует сама по мере входов.

Раскладка записи пароля: PHC-строка Argon2id и bcrypt по полям, что хранится в БД, что в KMS и что нигде

Функция Стойкость к GPU Когда уместна Параметры
Argon2id высокая, память настраивается выбор по умолчанию m=47104 КиБ, t=1, p=1 либо m=19456, t=2, p=1
scrypt (RFC 7914) высокая если Argon2 нет в стеке N=2^17, r=8, p=1
bcrypt средняя, память фиксирована зрелые экосистемы, легаси cost ≥ 12, минимум 10
PBKDF2-HMAC-SHA256 низкая, но одобрена регуляторами требования FIPS ≥ 600 000 итераций
SHA-256, MD5, «соль + SHA-1» нулевая никогда для паролей
  1. bcrypt обрезает вход на 72 байтах, а в кириллице это ~36 символов; некоторые реализации вдобавок обрываются на нулевом байте. Лечение — pre-hash bcrypt(base64(sha256(password))); base64 обязателен, иначе в бинарном дайджесте попадётся \x00.
  2. Параметры «на глазок». Меряйте на своём железе. Слишком дорогие параметры превращают форму входа в вектор отказа в обслуживании: тысяча одновременных попыток по 46 МиБ — это 46 ГБ памяти.
  3. Клиентское хеширование вместо серверного. Если клиент присылает sha256(password), то этот дайджест и есть пароль. Клиентский pre-hash допустим только как дополнение.

Как проверить, что починено. Все записи начинаются с явного префикса схемы ($argon2id$, $2b$); 32 hex-символа означают MD5 — это инцидент. SELECT count(*) - count(DISTINCT password_hash) FROM users возвращает 0 (соли уникальны). Время check_password попадает в целевой диапазон и одинаково для существующего и несуществующего логина. Юнит-тест: два вызова store_password с одним паролем дают разные строки. В SAST включено правило на запрет md5/sha1/sha256 в модулях аутентификации — см. Безопасная разработка.

Миграция без сброса паролей всей базе

Просить миллионы людей сменить пароль — плохой план. Старый хеш заворачивается в новый алгоритм (это делается без участия пользователя), а при следующем успешном входе запись переписывается начисто.

def check_password_migrating(row, password: str) -> bool:
    if row["scheme"] == "argon2id":
        ok = _verify_argon(row["password_hash"], _prehash(password))
    elif row["scheme"] == "argon2id-over-md5":            # старый MD5 завёрнут в Argon2
        ok = _verify_argon(row["password_hash"], hashlib.md5(password.encode()).hexdigest().encode())
    else:
        raise RuntimeError("неизвестная схема хранения")
    if ok:  # пароль в открытом виде есть только здесь и только сейчас
        db.execute("UPDATE users SET password_hash=%s, scheme='argon2id' WHERE id=%s",
                   (_hasher.hash(_prehash(password)), row["id"]))
    return ok

Порядок работ: одной миграцией пересчитать все старые хеши в argon2id-over-md5 — офлайн-перебор закрыт уже на этом шаге; затем включить дозапись начисто при входах; через год-полтора сбросить остаток неактивных аккаунтов.

Проверка по спискам утечек

NIST требует сверять новый пароль с базой скомпрометированных. Делается это без отправки пароля наружу — по k-анонимности: считаем SHA-1, отправляем 5 hex-символов префикса, получаем список суффиксов, сверяем локально (Pwned Passwords Range API).

digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()   # SHA-1 — индекс, не защита
resp = requests.get(f"https://api.pwnedpasswords.com/range/{digest[:5]}",  # наружу 5 символов
                    headers={"Add-Padding": "true"}, timeout=3)
breached = any(line.split(":")[0] == digest[5:] for line in resp.text.splitlines())

Проверять нужно при установке пароля, а не при каждом входе, и заранее решить поведение при недоступности сервиса (обычно fail-open с алертом, иначе внешний сервис становится точкой отказа регистрации). Для строгих требований к приватности берите офлайн-дамп хешей; обработка таких данных — тема статьи Приватность и соответствие.

Перечисление аккаунтов и утечки по времени

Уязвимо: 404 «Пользователь не найден» на одной ветке и 401 «Неверный пароль» на другой. Почему это работает. Форма логина превращается в оракул «существует ли такой e-mail». Список подтверждённых адресов удешевляет и целевой фишинг, и подстановку учёток из чужих утечек — атака идёт только по живым аккаунтам. CWE-203 «Observable Discrepancy». Тот же оракул прячется в регистрации («адрес уже занят»), в восстановлении и в разнице времени: если для несуществующего пользователя Argon2 не вызывается, ответ приходит за 3 мс вместо 250 мс, и одинаковые тексты ошибок уже не спасают.

Как чинить. Один текст, один код ответа, одна форма тела и одинаковое время на всех неуспешных ветках; хеш считается всегда, для отсутствующего пользователя — фиктивный. Полностью убрать перечисление удаётся не везде: в регистрации приходится сообщать о занятом адресе. Рабочий компромисс — отвечать формой «мы отправили письмо», а различие переносить в само письмо: «на этот адрес уже есть аккаунт, вот ссылка на вход» либо «подтвердите регистрацию».

Как проверить. На своём стенде сравните три случая — несуществующий адрес, существующий с неверным паролем, существующий с верным: должны совпадать код, тело, заголовки и распределение времени. Разница медиан больше 20–30 мс — сигнал. То же для /register, /password/reset, /mfa/verify.

Ограничение частоты: подбор, распыление, подстановка

Атака Форма Что ограничивать
Подбор много паролей к одному аккаунту попытки на аккаунт
Распыление один популярный пароль ко многим аккаунтам попытки на IP и подсеть, доля неудач глобально
Подстановка учёток пары из чужих утечек, по одной попытке на аккаунт репутация IP, поведение, проверка по спискам утечек

Наивное «5 неудач — блокируем на час» создаёт новую проблему: любой может заблокировать любого, зная только e-mail. Это отказ в обслуживании, оформленный как мера безопасности.

def login_gate(email: str, ip: str) -> Decision:
    per_account = counters.incr(f"fail:acct:{sha256(email)}", ttl=3600)
    if per_account > 100:
        return Decision.REQUIRE_STEP_UP                              # не блок, а усиление
    if per_account > 5:
        return Decision.DELAY(min(2 ** (per_account - 5), 30))       # экспонента, до 30 с
    if counters.incr(f"fail:ip:{ip}", ttl=3600) > 50 or \
       counters.rate("fail:global", 60) > baseline * 5:
        return Decision.CHALLENGE                                    # CAPTCHA или PoW
    return Decision.ALLOW

Принципы: задержка вместо блокировки; счётчик сбрасывается только после успешного второго фактора, а не первого; ключ нормализуется (User@Example.com и user@example.com — один аккаунт); успешный вход с нового устройства после серии неудач — повод для уведомления и step-up; CAPTCHA — последний рубеж, а не первый. CWE-307. Хранилище счётчиков обычно Redis; поведение при его недоступности решается заранее — fail-closed для админки, fail-open с алертом для массового сервиса.

Сессии: жизненный цикл

После проверки пароля нужно помнить, что этот браузер вошёл. Классика — серверная сессия: длинный случайный идентификатор в cookie, состояние на сервере. Альтернатива — самодостаточные токены с другим набором компромиссов, см. JWT и токены. Для браузерных приложений серверная сессия обычно безопаснее просто потому, что её можно мгновенно отозвать.

Оба таймаута нужны и по разным причинам. Idle ограничивает окно для того, кто подошёл к незаблокированному компьютеру. Absolute ограничивает срок жизни украденного идентификатора, который атакующий может держать «живым» фоновыми запросами сколько угодно. Отсутствие второго — CWE-613 «Insufficient Session Expiration».

Фиксация сессии

Уязвимо: после проверки пароля в существующую сессию просто дописывается session["user_id"] = user.id, идентификатор остаётся прежним. Почему это работает. Если приложение принимает идентификатор, навязанный извне (параметром URL, cookie с захваченного поддомена, через XSS на соседнем сервисе) и не меняет его при повышении привилегий, атакующий заранее знает значение, которое после входа жертвы станет аутентифицированным. CWE-384 «Session Fixation».

Как чинить — регенерировать идентификатор при каждой смене уровня доверия: анонимная → pending → активная → повышенная. Переносить при этом только безопасные данные (корзину, локаль), не флаги аутентификации.

func issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
    // 1. Уничтожаем старую запись НА СЕРВЕРЕ, а не только меняем cookie.
    if old, err := r.Cookie("__Host-sid"); err == nil {
        store.Delete(r.Context(), hashID(old.Value))
    }
    // 2. Новый идентификатор: 32 байта из crypto/rand — 256 бит энтропии.
    raw := make([]byte, 32)
    if _, err := rand.Read(raw); err != nil {
        return err
    }
    sid := base64.RawURLEncoding.EncodeToString(raw)
    // 3. В хранилище кладём ХЕШ идентификатора: дамп Redis не даёт живых сессий.
    rec := Session{UserID: userID, CreatedAt: time.Now(),
        AbsoluteExpiry: time.Now().Add(12 * time.Hour),
        IdleExpiry:     time.Now().Add(30 * time.Minute)}
    if err := store.Put(r.Context(), hashID(sid), rec, 12*time.Hour); err != nil {
        return err
    }
    // 4. __Host- требует Secure и Path=/ и ЗАПРЕЩАЕТ Domain. MaxAge не задаём:
    // сессионная cookie умирает вместе с браузером.
    http.SetCookie(w, &http.Cookie{Name: "__Host-sid", Value: sid, Path: "/",
        Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
    return nil
}

Флаги по важности: HttpOnly лишает XSS долговременного трофея (полностью от XSS не спасает — скрипт может действовать от имени пользователя и не читая cookie, см. XSS, CSRF и клиентские атаки); Secure обязателен всегда (см. Транспортная безопасность); SameSite — это защита от CSRF, а не от кражи, Strict для админок; префикс __Host- не даёт поддомену, захваченному через забытую CNAME-запись, поставить cookie на весь домен; хеш идентификатора в хранилище работает по тому же принципу, что и хеш пароля. Как проверить. Значение cookie до входа и после обязано различаться, а старое — перестать работать. curl -sI https://стенд/login | grep -i set-cookie показывает Secure, HttpOnly, SameSite и префикс. POST /logout удаляет запись на сервере: очистка только на клиенте бессмысленна, если значение уже скопировано.

Шаг «этот временной шаг ещё не использован» критичен: без него подсмотренный или ретранслированный код применяется повторно в пределах окна.

Хранилище сессий и «запомнить меня»

CREATE TABLE sessions (
    id_hash         bytea PRIMARY KEY,           -- SHA-256 от идентификатора, не сам ID
    user_id         uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    last_seen_at    timestamptz NOT NULL DEFAULT now(),
    absolute_expiry timestamptz NOT NULL,
    ip_prefix       inet,                        -- /24 или /64, не полный адрес
    ua_hash         bytea,                       -- для списка устройств в кабинете
    amr             text[] NOT NULL DEFAULT '{}' -- чем подтверждено: pwd, otp, webauthn
);
CREATE INDEX ON sessions (user_id);

Колонка amr (authentication methods references) не украшение: она позволяет требовать для перевода денег сессию, подтверждённую passkey, а не только паролем. Усечённый ip_prefix — компромисс между расследованием инцидентов и минимизацией персональных данных.

«Запомнить меня» — отдельный долгоживущий токен, а не сессия на год. Проверенная схема — селектор + верификатор (заметка Барри Джаспана): в cookie пара selector:validator, в базе selector открыто (по нему ищем) и хеш validator (его сравниваем). Токен одноразовый: при использовании выдаётся новый, старый инвалидируется. Предъявленный уже использованный токен — признак кражи: гасим всю цепочку и уведомляем владельца. Долгоживущая сессия не даёт полных прав: смена пароля, почты, отключение MFA и платежи требуют step-up — это состояние Elevated на диаграмме выше.

Второй фактор

TOTP: механика и корректная проверка

TOTP (RFC 6238) — надстройка над HOTP (RFC 4226): вместо счётчика берётся номер временного шага floor(unix_time / 30). Обе стороны знают общий секрет, считают HMAC-SHA1 от номера шага и применяют динамическое усечение до 6 цифр. Сеть между приложением и сервером не нужна — только приблизительно синхронные часы.

def totp_code(secret: bytes, step: int, digits: int = 6) -> str:
    """Код для номера шага. Динамическое усечение — RFC 4226 §5.3."""
    mac = hmac.new(secret, struct.pack(">Q", step), hashlib.sha1).digest()
    offset = mac[-1] & 0x0F                            # младшие 4 бита последнего байта
    binary = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(binary % (10 ** digits)).zfill(digits)

def verify_totp(user_id: str, secret: bytes, code: str, period=30, drift=1) -> bool:
    now_step = int(time.time()) // period
    for candidate in range(now_step - drift, now_step + drift + 1):
        if hmac.compare_digest(totp_code(secret, candidate), code):   # постоянное время
            # Один временной шаг — один вход; ключ anti-replay живёт дольше окна.
            return bool(cache.set_if_absent(f"totp:{user_id}:{candidate}", "used",
                                            ttl=period * (2 * drift + 2)))
    return False

Что делают неправильно чаще всего: окно ±10 шагов вместо ±1 (каждый шаг линейно увеличивает число валидных кодов); отсутствие anti-replay; отсутствие отдельного лимита попыток на ввод кода (шесть цифр — миллион вариантов, без лимита перебор вполне реален); секрет в базе открытым текстом — его надо шифровать ключом из KMS (см. Секреты и ключи), иначе дамп таблицы обнуляет второй фактор для всех; привязка без подтверждения — пользователь обязан ввести код сразу после сканирования QR; отключение MFA без step-up и уведомления — иначе угнанная сессия просто снимет второй фактор.

Канал доставки тоже важен. SMS в SP 800-63B отнесён к «restricted»: перевыпуск SIM и перехват через SS7 — известные и воспроизводимые проблемы. Если SMS остаётся, считайте его слабым фактором, не разрешайте им подтверждать смену пароля и предлагайте переход на приложение или passkey.

WebAuthn и passkeys: другой класс защиты

TOTP поднимает цену атаки, но не меняет её принцип: секрет по-прежнему ретранслируем. Пользователя приводят на похожий домен, он вводит пароль и код, прокси мгновенно передаёт их настоящему сервису и получает сессию.

Три способа входа под фишинговым прокси: пароль и одноразовый код ретранслируются, passkey — нет, потому что подпись привязана к домену

WebAuthn ломает схему на уровне протокола. При регистрации аутентификатор (Secure Enclave, TPM, аппаратный ключ) создаёт пару ключей для конкретного rpId; закрытый ключ физически не покидает устройство. При входе сервер присылает случайный challenge, браузер формирует clientDataJSON, куда сам вкладывает origin текущей вкладки, а аутентификатор подписывает authenticatorData || SHA-256(clientDataJSON), где в authenticatorData лежит rpIdHash.

Серверная проверка — место, где ошибаются. Обязательный набор сверок: clientData.type == "webauthn.get"; challenge сравнивается за постоянное время и гасится немедленно, храниться он должен в серверной сессии, а не у клиента; origin сверяется точным равенством строкstartsWith обходится доменом https://app.example.com.evil.tld; rpIdHash (первые 32 байта authenticatorData) равен SHA-256 от вашего rpId; бит 0 флагов (User Present) выставлен, бит 2 (User Verified) — если вы требуете верификацию; подпись проверяется по конкатенации authenticatorData и SHA-256 от clientDataJSON; signCount вырос относительно сохранённого — иначе возможен клон аутентификатора (у синхронизируемых passkeys счётчик часто равен нулю, тогда проверка пропускается). Библиотеку берите готовую (@simplewebauthn/server, py_webauthn), но понимать, что именно она сверяет, обязательно.

Миграция: passkey добавляется как дополнительный способ входа, пароль остаётся резервным; затем для аккаунтов с passkey пароль отключается по желанию пользователя; для администраторов фишинг-устойчивый фактор делается обязательным. Полный отказ от пароля разом ломает сценарий «новое устройство, старое утеряно» — сначала нужен работающий процесс восстановления.

Резервные коды, step-up, усталость от push

Резервные коды — это пароли: генерируются из CSPRNG (≥ 64 бит энтропии на код), хранятся хешами, гасятся при первом использовании, показываются один раз, регенерируются только пачкой с полной инвалидацией старой, каждое использование сопровождается уведомлением. Step-up — повторное подтверждение внутри живой сессии при смене пароля, почты или телефона, отключении MFA, добавлении способа входа, выпуске API-ключа, платеже, доступе к персональным данным других людей; реализуется отметкой времени последней сильной проверки: if now - session.last_strong_auth > 5 min: require_reauth(). MFA fatigue (push bombing) — атака на человека: имея валидный пароль, злоумышленник шлёт десятки push, пока сонный пользователь не нажмёт «Подтвердить»; контрмеры — подтверждение числом (двузначное число с экрана входа вводится в приложении), контекст в самом push (город, IP, приложение), жёсткий лимит запросов подтверждения в минуту, автоблокировка после серии отклонений и алерт.

Вход по ссылке из письма привлекателен простотой, но цена такая: защищённость сводится к защищённости почтового ящика; корпоративные сканеры безопасности регулярно «открывают» ссылку раньше пользователя и гасят одноразовый токен; ссылка оседает в истории браузера, логах прокси и заголовке Referer; фишинг работает ровно так же. Минимальные требования при внедрении: токен из CSPRNG ≥ 128 бит, TTL 10–15 минут, атомарное одноразовое погашение, привязка к тому же браузеру (через дополнительную cookie), запрет на использование ссылки для смены пароля или почты, Referrer-Policy: no-referrer на странице приземления.

Восстановление доступа: чёрный ход равной силы

Самая недооценённая часть: можно выстроить Argon2id, passkey и step-up, а потом отдать аккаунт через форму «забыли пароль». Правило: процесс восстановления не слабее основного входа. CWE-640.

# УЯЗВИМО: четыре дефекта в пяти строках.
user = users.find_by_email(request.form["email"])
if not user:
    return "Пользователь не найден", 404                        # 1. перечисление
token = str(random.randint(100000, 999999))                     # 2. не CSPRNG, 20 бит
db.execute("UPDATE users SET reset_token=%s WHERE id=%s", (token, user.id))  # 3. открыто, бессрочно
link = f"https://{request.headers['Host']}/reset?t={token}"     # 4. домен из заголовка

Почему это работает. random не криптографический: его состояние восстанавливается по нескольким выданным значениям, а шестизначное число перебирается за миллион запросов, которые никто не считает. Токен лежит открытым текстом — доступ к read-only реплике даёт вход в любой аккаунт. Срока и одноразовости нет: ссылка годовалой давности из почтового архива работает. Домен берётся из подконтрольного клиенту заголовка Host — сервис можно заставить отправить письмо со ссылкой на чужой домен (CWE-644).

def consume_reset_token(raw: str) -> str | None:
    """Возвращает user_id ровно один раз: гонка исключена условием внутри UPDATE.
    Выдача: raw = secrets.token_urlsafe(32), в БД — только sha256(raw) и expires_at."""
    row = db.query_one(
        """UPDATE password_resets SET used_at = now()
            WHERE token_hash = %s AND used_at IS NULL AND expires_at > now()
        RETURNING user_id""",
        (hashlib.sha256(raw.encode()).digest(),))
    return row["user_id"] if row else None

Токен хешируется быстрой SHA-256, и это корректно: в отличие от пароля, он полностью случайный и содержит 256 бит энтропии — перебирать нечего. Медленная функция нужна там, где вход имеет низкую энтропию.

Что теряют чаще всего: атомарное погашение (SELECT, затем UPDATE — гонка, два параллельных запроса пройдут оба); base URL из конфигурации, а не из Host, X-Forwarded-Host или Referer; сброс всех сессий (оставлять живой чужую сессию после сброса по причине компрометации бессмысленно); сохранение MFA — иначе восстановление становится обходом второго фактора; текущий пароль при смене из кабинета (CWE-620, иначе угнанная через XSS сессия конвертируется в постоянный доступ); подтверждение смены e-mail на обоих адресах с окном на отмену; отсутствие секретных вопросов — «девичья фамилия матери» есть в соцсетях. Отдельно нужен явный процесс для «потерял всё»: медленный, с ожиданием 24–72 часа, уведомлением на все известные контакты и возможностью отмены — именно он становится мишенью социальной инженерии.

Как проверить. Второй запрос сброса убивает первый токен; через 16 минут ссылка мертва; после сброса параллельная сессия в другом браузере разлогинена; в password_resets лежит бинарный хеш, а не читаемая строка; запрос с подменённым Host порождает письмо со ссылкой на ваш настоящий домен.

Логирование и уведомления

Логировать: login_success (user_id, amr, IP-префикс, устройство), login_failed (хеш логина, IP-префикс, причина в общем виде), mfa_enrolled / mfa_removed, password_changed / password_reset, email_changed (адреса маскированы), session_revoked, step_up_passed. Не логировать никогда: пароль (в том числе неверный — люди опечатываются в соседнем поле), полное значение session ID, код TOTP, токен сброса, секрет MFA, заголовок Authorization. Типичные источники утечки — обработчик исключений, пишущий тело запроса целиком, и APM-трассировка с полными заголовками; фильтры чувствительных полей настраиваются один раз и покрываются тестом. Уведомления пользователю — дешёвый и очень эффективный детектор: чаще всего о захвате аккаунта первым узнаёт владелец. Письмо о входе с нового устройства, смене пароля, добавлении или удалении фактора, использовании резервного кода — с кнопкой «это не я», которая отзывает все сессии. Полезные алерты: всплеск неудач по многим аккаунтам с одной подсети; успешный вход после серии неудач; массовое использование резервных кодов; удаление MFA у нескольких аккаунтов подряд. Как встроить это в конвейер — в статье Безопасность в CI/CD.

Чек-лист приёмки

Проверки — только на своей инфраструктуре или при письменном разрешении владельца. Методика — OWASP ASVS (раздел про аутентификацию) и OWASP WSTG, блок WSTG-ATHN; прикладная сторона — Тестирование безопасности.

  • Argon2id с измеренными параметрами; уникальная соль из CSPRNG; перец вне БД; автоматический rehash; пароля нет в логах, метриках и трассировках
  • Минимум 8 и приём не менее 64 символов, Unicode и пробелы, вставка из буфера работает, композиционных правил и ротации нет, есть проверка по спискам утечек
  • Ответ на неверный логин и неверный пароль неотличим по коду, телу и времени; лимиты по аккаунту, IP и глобальные; отдельный лимит на второй фактор
  • Идентификатор сессии ≥ 128 бит из CSPRNG, в хранилище его хеш; регенерация при каждом повышении доверия; Secure, HttpOnly, SameSite, префикс __Host-, без Domain
  • Заданы оба таймаута; выход удаляет запись на сервере; есть «выйти со всех устройств»; remember-me одноразовый, ротируемый и не даёт прав на чувствительные действия
  • Секреты TOTP зашифрованы ключом из KMS; окно ±1 шаг; anti-replay; резервные коды хешированы и одноразовы; отключение MFA требует step-up и уведомления; для администраторов обязателен фишинг-устойчивый фактор; секретных вопросов нет нигде
  • Токен сброса ≥ 128 бит из CSPRNG, в БД хеш, TTL ≤ 15 минут, погашение атомарным UPDATE; ссылка из конфигурации; сброс отзывает сессии и не отключает MFA; смена пароля из кабинета требует текущего, смена e-mail подтверждается на обоих адресах

Типичные ошибки

  1. Проверить пароль и «забыть» про второй фактор. Полноценная сессия выдаётся после пароля, а шаг MFA живёт на фронтенде. До подтверждения второго фактора сессия обязана быть pending и не открывать ни одного защищённого эндпоинта.
  2. Считать Remember me эквивалентом свежей аутентификации. Долгий токен — удобство, а не подтверждение личности.
  3. Держать хеши паролей и токены сброса в одной таблице без разграничения доступа. Аналитическая реплика с колонкой reset_token — готовый обход входа.
  4. Разные HTTP-коды на ветках логина и сравнение секретов через ==. 401 против 403 против 404 — уже оракул; для сравнения есть hmac.compare_digest, crypto.timingSafeEqual, subtle.ConstantTimeCompare.
  5. Писать криптографию входа самому. Свой «улучшенный» TOTP или своя схема хешей — почти гарантированные дефекты; берите библиотеки и читайте, что именно они проверяют (см. Прикладная криптография).
  6. Игнорировать гонки. Два параллельных сброса с одним токеном, два параллельных логина, гонка при инкременте счётчика — закрывается на уровне БД, а не приложения; общая теория — в статье Идемпотентность и доставка.
  7. Игнорировать пользовательский опыт. Агрессивные блокировки, запрет вставки пароля, обязательная ротация выталкивают людей на слабые пароли. Безопасность, которую обходят, не работает; см. Формы и валидация.

Итог

Аутентификация — четыре подсистемы, и слабость любой обнуляет остальные. Хранение секрета: Argon2id с измеренными параметрами, уникальная соль, перец вне БД, автоматический rehash — функция общего назначения для паролей неприменима принципиально, а не «нежелательна». Проверка: одинаковые ответы и время на всех неуспешных ветках, многоуровневые лимиты с экспоненциальной задержкой, сравнение секретов за постоянное время. Сессия: идентификатор из CSPRNG, хеш в хранилище, регенерация при каждом повышении доверия, правильные флаги cookie, два таймаута, мгновенный отзыв, step-up для чувствительных действий. Восстановление: та же строгость, что у основного входа — одноразовый короткоживущий токен, хеш в базе, отзыв всех сессий, сохранение MFA, уведомления.

Стратегическое направление одно: уходить от ретранслируемых секретов. Passkey и FIDO2 меняют не силу криптографии, а модель — секрет привязан к домену и не покидает устройство, поэтому классический фишинг перестаёт работать в принципе. Пароль ещё долго останется резервным путём, но новое имеет смысл проектировать сразу вокруг фишинг-устойчивого фактора.

Источники

Что дальше

Мы разобрали, как сервис сам проверяет своих пользователей. Следующий шаг — делегирование этой проверки внешнему провайдеру: какие бывают потоки, зачем нужен PKCE и почему state — не формальность.

OAuth 2.0 и OpenID Connect: потоки, PKCE, типичные ошибки внедрения

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов