Один фактор — пароль или вход через провайдера — падает вместе с утёкшей учёткой. Второй фактор ломает эту одноходовку: даже зная пароль, злоумышленник не пройдёт без кода из приложения. TOTP — самый практичный второй фактор: не требует связи, работает офлайн, стандартизован. Эта статья — про то, как он устроен, как его правильно внедрить (секрет, recovery, step-up) и почему SMS-коды — плохая замена.
Часть серии «Авторизация: токены, сессии, refresh». Развивает вход и OTP до полноценного второго фактора.
В статье
- Зачем второй фактор
- TOTP: RFC 6238 и как устроен код
- Enrollment: секрет и QR
- TOTP vs SMS
- Анти-replay и анти-брутфорс
- Recovery-коды: доступ без телефона
- Step-up: свежий MFA только на опасное действие
- Куда дальше: TOTP всё ещё фишится
- Источники
Зачем второй фактор
Вся предыдущая часть серии — про то, как сервер убеждается, что перед ним тот самый пользователь, и как это подтверждение живёт в виде сессии. Но у любого из разобранных способов входа — пароль, email-код, OAuth (статья 4) — есть общее уязвимое место: это один фактор. Утёк пароль из чужой утечки, где пользователь его переиспользовал; фишинговая страница выманила email-код; скомпрометирован OAuth-провайдер — и злоумышленник проходит аутентификацию с первой попытки, потому что предъявить нужно было ровно одну вещь.
MFA (multi-factor authentication) не устраняет компрометацию первого фактора — она делает её недостаточной. Факторы принято делить на три рода: что вы знаете (пароль, PIN), чем вы владеете (телефон с приложением-генератором, аппаратный ключ) и чем вы являетесь (биометрия). Второй фактор из другого рода закрывает ровно тот сценарий, где годами массово утекают базы паролей: даже имея верную пару логин/пароль, атакующий не владеет вторым секретом, который никогда не передавался по сети в открытом виде и не хранится ни в одной чужой базе.
TOTP (Time-based One-Time Password) — самый практичный вариант второго фактора владения: код генерируется на устройстве пользователя офлайн, без SMS-канала, без пуш-уведомлений, стандартизован RFC 6238 и поддержан любым authenticator-приложением. Дальше — как он устроен, что в нём легко сделать неправильно (анти-replay, хранение секрета) и как не потерять пользователя навсегда при утере телефона.
TOTP: RFC 6238 и как устроен код
TOTP — это HOTP (HMAC-based One-Time Password, RFC 4226) с одной заменой: вместо счётчика, который сервер и клиент синхронизируют явно, используется время, которое и так более-менее синхронно у обеих сторон. Формула:
step = floor(unix_time / period) # period = 30с
code = Truncate(HMAC-SHA1(secret, step)) # усечение до N цифр
Секрет — общий случайный ключ, известный только серверу и приложению-аутентификатору пользователя (Google Authenticator, Aegis, встроенный в менеджер паролей и т.д.). Каждые period секунд текущий Unix-time делится на период и округляется вниз — получается time-step, целое число, растущее на единицу каждые 30 секунд. Step прогоняется через HMAC с секретом в качестве ключа, результат усекается динамическим алгоритмом (описан в RFC 4226) до нужного числа десятичных цифр. Ни секрет, ни какой-либо секретный материал никогда не передаются по сети при проверке — по сети едут только шесть цифр, которые бесполезны за пределами 30-секундного окна.
В стенде (security/auth/totp) эти параметры зафиксированы явно, через github.com/pquerna/otp (v1.5.0):
const (
totpPeriod = 30
totpSkew = 1
totpDigits = otp.DigitsSix
)
Алгоритм хэширования — SHA1. Это не оговорка и не упрощение стенда: RFC 6238 определил TOTP именно на SHA1, и подавляющее большинство authenticator-приложений в мире (включая Google Authenticator) умеют работать только с ним — SHA256/SHA512 в спецификации присутствуют как опция, но экосистема клиентов их почти не поддерживает, поэтому взаимная совместимость на практике означает SHA1. Шесть цифр — тоже стандарт де-факто: пространство кода 10⁶, и в паре с 30-секундным окном и анти-брутфорсом (ниже) этого достаточно.
Окно допуска (skew). Часы сервера и часы телефона не идеально синхронны — минутный дрейф не редкость. Если проверять код строго на текущий step, легитимный пользователь с чуть отстающими или спешащими часами будет получать «неверный код» без всякой атаки. totpSkew = 1 расширяет проверку на соседние периоды: сервер пересчитывает ожидаемый код не только для текущего step, но и для step-1 и step+1, то есть фактически принимает код в окне около 60–90 секунд вокруг реального времени сервера:
nowStep := time.Now().Unix() / totpPeriod
for step := nowStep - totpSkew; step <= nowStep+totpSkew; step++ {
expected, _ := totp.GenerateCodeCustom(secret, time.Unix(step*totpPeriod, 0), totp.ValidateOpts{
Period: totpPeriod, Digits: totpDigits, Algorithm: otp.AlgorithmSHA1,
})
if token.SafeEqual(expected, code) {
matched, matchedStep = true, step
break
}
}
Сравнение — константное по времени (token.SafeEqual), а не ==: код, пусть и производный от секрета, всё равно секретный материал на 30 секунд, и сравнение строк с ранним выходом на первом несовпадающем байте теоретически позволяет замерять расхождение по времени ответа. Более широкий skew (±2, ±3) звучит как более щедрая толерантность к рассинхрону, но и удлиняет окно, в котором подсмотренный код остаётся действителен — ±1 — обычный практический баланс.
Enrollment: секрет и QR
Подключение TOTP («enrollment») — единственный момент во всём жизненном цикле, когда секрет и recovery-коды видны в открытом виде. Сервер генерирует секрет, отдаёт его клиенту (обычно — в виде QR-кода со ссылкой otpauth://, которую распознаёт приложение-аутентификатор) и сохраняет копию у себя:
func Enroll(ctx context.Context, rdb *redis.Client, userID string) (secret, otpauthURI string, recovery []string, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer, AccountName: userID,
Period: totpPeriod, Digits: totpDigits, Algorithm: otp.AlgorithmSHA1,
})
// ...
return key.Secret(), key.URL(), recovery, nil
}
key.URL() — это и есть otpauth://totp/khorost-auth-demo:u1?secret=...&issuer=khorost-auth-demo&period=30&digits=6: стандартная схема URI, которую понимает любое authenticator-приложение — достаточно превратить строку в QR-код на экране (сам стенд отдаёт otpauth_uri в JSON, рендер QR — забота фронтенда, не входит в объём демо). После этого секрет живёт в Redis (totp:{userID}, поле secret) и с этого момента больше никогда не отдаётся клиенту целиком: последующие операции — только «проверить код против секрета», а не «показать секрет ещё раз».
Важная деталь для повторной регистрации: Enroll перед записью делает Del по всему ключу totp:{userID} — без этого при re-enrollment (пользователь сбросил TOTP и настраивает заново) старые recovery-коды и last_step анти-replая пережили бы новую настройку, и в системе одновременно были бы валидны и старые, и новые recovery-коды. Тест TestReEnrollInvalidatesOldRecovery в стенде это фиксирует явно.
TOTP vs SMS
SMS-код как второй фактор долго был массовым выбором — он не требует от пользователя ставить отдельное приложение. Но у него системная слабость: код едет через телефонную сеть, а телефонная сеть — не то место, где стоит держать секрет.
- SIM-swap. Атакующий убеждает оператора связи (социальной инженерией или через инсайдера) перевыпустить SIM-карту жертвы на своё устройство. Дальше все SMS, включая коды MFA и коды сброса пароля, приходят ему. Это не гипотетика — массовый вектор атак на криптокошельки и банковские аккаунты именно через операторов, а не через взлом самого сервиса.
- Перехват на уровне сети. SS7 (сигнальный протокол между операторами) спроектирован в эпоху доверенных телефонных сетей и не аутентифицирует запросы между операторами должным образом — SMS можно перенаправить, зная номер жертвы, без физического доступа к SIM. Это известная и неоднократно продемонстрированная атака, доступная не только спецслужбам.
- Зависимость от третьей стороны. Доставка SMS — не гарантирована и не мгновенна: задержки, блокировки роумингом, стоимость на международных номерах. Каждая доставка — ещё один внешний сервис (SMS-шлюз) в цепочке доверия.
TOTP не имеет ни одной из этих проблем структурно: код не передаётся ни по какому каналу связи вообще — он вычисляется локально на устройстве из уже имеющегося секрета. Скомпрометировать SIM бесполезно: секрет живёт не в SIM, а в приложении-аутентификаторе. NIST SP 800-63B ещё в редакции 2017 года понизил статус SMS-OTP до «restricted authenticator» именно по этим причинам — это не эстетическое предпочтение TOTP, а зафиксированная в стандарте позиция. Практический недостаток TOTP ровно один: пользователю нужно ставить и не терять приложение-аутентификатор — отсюда важность recovery-кодов (ниже).
Анти-replay и анти-брутфорс
Skew-окно в 60–90 секунд — это подарок и атакующему тоже: если код был подсмотрен (через плечо, в логе, в истории браузера при автозаполнении), в пределах этого окна он формально всё ещё «правильный» с точки зрения формулы HOTP. Наивная реализация TOTP-проверки (сравнить с ожидаемым — и всё) уязвима к replay: тот же код можно предъявить дважды в пределах окна, и оба раза он пройдёт проверку.
Стенд закрывает это не эвристикой «код похож на использованный», а маленьким, но принципиальным механизмом: для каждого пользователя хранится last_step — номер последнего принятого time-step. Условие приёма — строго «новый step больше последнего принятого», а не «код совпал». Продвижение last_step и проверка делаются одной атомарной операцией — Lua-скриптом, который Redis исполняет однопоточно, без гонки между конкурентными запросами Verify:
local last = redis.call('HGET', KEYS[1], 'last_step')
if last and tonumber(last) >= tonumber(ARGV[1]) then
return 0
end
redis.call('HSET', KEYS[1], 'last_step', ARGV[1])
return 1
Если бы это было «прочитать last_step, сравнить в Go, записать новый» тремя отдельными командами, между чтением и записью мог бы вклиниться параллельный запрос с тем же кодом — классический TOCTOU. Lua-скрипт в Redis — простой и надёжный способ получить атомарность без отдельного распределённого лока. TestVerifyRejectsReplay в стенде прогоняет ровно этот сценарий: первый Verify с валидным кодом проходит, второй Verify с тем же самым кодом (тот же step) — отклоняется, хотя формально код всё ещё «в пределах окна допуска».
У этой строгости есть цена, и её стоит проговорить честно: last_step продвигается только вперёд и необратимо. Если пользователь дважды кликнул «подтвердить» с одним и тем же кодом в форме (двойной сабмит) — второй запрос будет отклонён точно так же, как настоящий replay-атакующий, потому что для сервера это неотличимые ситуации: один и тот же step, предъявленный второй раз. Разработчику клиента приходится закрывать двойной клик на своей стороне (disable кнопки после первого сабмита), а не полагаться на сервер — сервер сознательно не умеет отличать «случайный дубль легитимного клиента» от «атакующий предъявляет подсмотренный код».
Отдельно, но по соседству — анти-брутфорс. Шестизначный код — это пространство всего 10⁶ вариантов; при окне допуска в 60–90 секунд и отсутствии лимита попыток код реально перебираем. /totp/verify считает неудачные попытки per-userID (totp:rl:{userID}, лимит 5 попыток за 60 секунд) и при превышении лимита отклоняет любой код — включая правильный — до истечения окна:
const totpRLLimit, totpRLWindow = 5, 60 * time.Second
Отказ ради корректного кода после исчерпания лимита — не баг, а намеренное решение: если бы лимит переставал действовать в момент правильного ответа, сам факт «лимит вдруг снялся» становился бы оракулом для атакующего, перебирающего код. Успешная проверка сбрасывает счётчик (TestVerifyRateLimitResetsOnSuccess) — пользователь, один раз ошибившийся пальцем и затем введший верный код, не остаётся заблокированным до конца минуты.
Recovery-коды: доступ без телефона
Второй фактор владения ломается ровно тогда, когда владение теряется: телефон разбит, потерян, украден, приложение снесено при переустановке без бэкапа. Без запасного пути пользователь с MFA, но без телефона, не может войти вообще — это реальный кейс службы поддержки, а не теория.
Recovery-коды — заранее сгенерированный при enrollment набор одноразовых кодов, которые пользователь сохраняет отдельно от телефона (менеджер паролей, распечатка, сейф). Стенд генерирует 10 кодов на каждый enrollment:
for i := 0; i < recoveryCount; i++ {
plain, _ := token.OpaqueHex(8)
recovery[i] = plain
fields["recovery:"+hashRecoveryCode(plain)] = "1"
}
Ключевая деталь хранения: в Redis попадает не сам код, а sha256(code), в качестве поля хэша totp:{userID} (recovery:{sha256}). Это то же рассуждение, что и для паролей — компрометация хранилища не должна означать компрометацию всех recovery-кодов в открытом виде. Открытым текстом код виден ровно один раз — в ответе POST /totp/enroll в момент генерации; после этого сервер знает только хэш и не может показать код повторно, только перевыпустить весь набор через re-enrollment.
Использование — одноразовое по конструкции, не по соглашению:
func UseRecovery(ctx context.Context, rdb *redis.Client, userID, code string) (bool, error) {
field := "recovery:" + hashRecoveryCode(code)
exists, err := rdb.HExists(ctx, totpKey(userID), field).Result()
if !exists { return false, nil }
if err := rdb.HDel(ctx, totpKey(userID), field).Err(); err != nil { ... }
return true, nil
}
HDel сразу после успешной проверки — код физически исчезает из хранилища, повторное предъявление того же значения не найдёт поля и вернёт false (TestRecoverySingleUse). Успешный вход по recovery-коду в стенде поднимает step-up точно так же, как успешный TOTP (см. ниже) — с точки зрения «свежести MFA-подтверждения» recovery-код равносилен коду из приложения, потому что доказывает владение тем же секретом восстановления, который выдавался только легитимному владельцу аккаунта при enrollment.
Step-up: свежий MFA только на опасное действие
Требовать второй фактор на каждый запрос неоправданно — пользователь и так вошёл, сессия жива (модели сессий разобраны в первой статье). Но у части действий цена ошибки выше, чем у обычного чтения: смена email или пароля, привязка новой платёжной карты, вывод средств. Здесь применяется отдельный паттерн — step-up authentication: обычной, уже установленной сессии недостаточно, требуется свежее подтверждение MFA, ограниченное по времени.
В стенде это отдельный TTL-флаг поверх session ID, не относящийся к самой сессии:
func MarkStepUp(ctx context.Context, rdb *redis.Client, sessionID string) error {
return rdb.Set(ctx, stepupKey(sessionID), "1", stepupTTL).Err() // stepupTTL = 5 * time.Minute
}
func RequireStepUp(ctx context.Context, rdb *redis.Client, sessionID string) (bool, error) {
n, err := rdb.Exists(ctx, stepupKey(sessionID)).Result()
return n > 0, err
}
Успешная проверка TOTP-кода или recovery-кода вызывает MarkStepUp для текущей сессии — stepup:{sessionID} в Redis с TTL 5 минут. Защищённый эндпоинт (POST /sensitive) перед выполнением действия проверяет RequireStepUp: если флага нет или он истёк, ответ — 403 step_up_required, даже если основная сессия пользователя абсолютно валидна:
func (h *totpHandler) sensitive(w http.ResponseWriter, r *http.Request) {
sid := sessionID(r)
ok, _ := RequireStepUp(r.Context(), h.rdb, sid)
if !ok {
writeError(w, http.StatusForbidden, "step_up_required")
return
}
// выполнить опасное действие
}
TestStepUpExpires в стенде промораживает время через miniredis.FastForward(6 * time.Minute) и проверяет, что подтверждение, которое было живым сразу после MarkStepUp, протухает по истечении TTL — пятиминутное окно не продлевается активностью, это именно «свежесть», а не «сессия с MFA», и пользователю, который подтвердил MFA десять минут назад и теперь хочет сменить email, придётся ввести код заново.
Важная граница демо, которую стоит проговорить честно: стенд totp/ не создаёт саму первичную сессию — он принимает готовый sessionID через cookie session_id или заголовок X-Session-Id и работает поверх неё. В реальной системе session ID приходит из того же механизма, что разобран в статье про opaque-сессии или статье про JWT+refresh; step-up здесь — надстройка над уже существующей моделью сессий, а не альтернатива ей.
И ещё один компромисс демо, важный для переноса в прод: TOTP-секрет в totp:{userID} хранится открытым текстом, не шифрованным at-rest. Компрометация Redis раскрывает секреты всех зарегистрированных пользователей разом — в отличие от recovery-кодов, которые хранятся хэшированными не просто так. В проде секрет шифруется симметричным ключом вне Redis (управление ключом — через KMS или HSM) перед записью и расшифровывается только на момент вычисления кода; это не деталь реализации, а отдельная граница ответственности, которую demo-стенд сознательно не закрывает — README прямо перечисляет это в списке честных компромиссов.
Куда дальше: TOTP всё ещё фишится
TOTP закрывает кражу пароля и подмену SIM, но не закрывает фишинг целиком. Поддельная страница логина, визуально неотличимая от настоящей, точно так же попросит ввести шестизначный код — и пользователь, уже привыкший его вводить, введёт. Атакующий в реальном времени пересылает украденный код на настоящий сайт (adversary-in-the-middle, «реальном времени фишинг») и получает сессию за те же 30–90 секунд, пока код ещё действителен. TOTP не проверяет, с каким сайтом разговаривает пользователь — он лишь доказывает владение секретом, а секрет вводится вручную, куда угодно.
Это структурное ограничение любого фактора, основанного на коде, который человек читает и перепечатывает. Следующая, восьмая статья серии — «Passkeys и WebAuthn» — разбирает подход, который закрывает именно этот случай: криптографическая привязка к origin на уровне протокола, из-за которой поддельный сайт физически не может получить рабочий ответ, даже если пользователь на нём ничего не заподозрит. Это не замена TOTP «потому что он устарел» — TOTP остаётся практичным вторым фактором там, где WebAuthn пока недоступен, — а следующий шаг для тех случаев, где фишинг остаётся реальной угрозой.
Комментарии