Способов войти много — email-код, OAuth через google или vk, OTP, Telegram — но все они должны сходиться в одну точку: успешная аутентификация любым методом выдаёт одну и ту же сессию или пару токенов. Отдельная задача — регистрация: чаще всего аккаунт создаётся автоматически при первом входе, а дальше встаёт вопрос объединения методов (один человек, вошедший сегодня по email, а завтра через Telegram) и коллизий email/phone.
Это четвёртая статья серии «Авторизация: токены, сессии, refresh». Она нанизывает разные методы входа на модели сессий из статей 2 и 3.
В статье
- Единая точка выпуска токенов
- Email-код и OTP: TTL, лимит попыток, rate-limit
- OAuth поверх mock-OIDC: state, PKCE, callback
- Telegram: canonical widget-HMAC против небезопасного OAuth-варианта
- Регистрация: авто-создание аккаунта при первом входе
- Почему методы не сливаются в один аккаунт
- Куда дальше по серии
- Источники
Единая точка выпуска токенов
Прежде чем разбирать конкретные методы, стоит закрепить главный тезис статьи: OTP, OAuth и оба варианта Telegram-входа — это три (фактически четыре) совершенно разных способа установить личность, но все они сходятся к одному и тому же вызову. В стенде (security/auth/login-methods) это session.CreateTokenPair — тот же пакет, что и в третьей статье про JWT+refresh:
access, _, err := session.CreateTokenPair(r.Context(), h.rdb, h.secret, acc, "otp")
// ...
access, _, err := session.CreateTokenPair(r.Context(), h.rdb, h.secret, acc, provider)
// ...
access, _, err := session.CreateTokenPair(r.Context(), h.rdb, h.secret, acc, "telegram")
Четыре точки входа (otpVerify, callbackOAuth, telegramWidget, telegramOAuth) вызывают одну и ту же функцию с одним и тем же контрактом: session.Account{ID, Nick, Roles} на входе, пара access/refresh на выходе. loginMethod — последний параметр — не косметика: он попадает в rs:{refresh} (поле lm) и остаётся в refresh-сессии как метка «чем именно вошёл этот пользователь», доступная и для аудита, и для UI («вы вошли через Telegram»).
Практическое следствие: добавление нового метода входа (условный WebAuthn или ещё один OAuth-провайдер) не требует ни новой модели токена, ни нового middleware на стороне потребителей — только код, который умеет проверить этот конкретный способ доказать личность и на выходе дать session.Account. Вся сложность методов входа изолирована до момента выпуска токена; после него все запросы неотличимы друг от друга, что бы ни привело пользователя на сайт.
Cookie, которую ставит login-methods, — только access (app_at, httpOnly, SameSite=Lax, TTL = session.AccessTTL, те же 15 минут, что и в ст.3). Refresh-cookie здесь намеренно не публикуется: CreateTokenPair всё равно выпускает refresh-токен и кладёт его в Redis (rs:{refresh}, самоистечёт по RefreshTTL — 168 часов), но у этого демо-сервиса нет /auth/refresh-эндпоинта, поэтому отправлять клиенту cookie, которую некому предъявить, было бы мёртвым весом на каждый запрос. Клиентскую сторону обновления (несколько вкладок, гонка за один refresh) разбирает пятая статья.
Email-код и OTP: TTL, лимит попыток, rate-limit
Самый простой по интерфейсу метод — беспарольный вход по одноразовому коду — оказывается самым насыщенным по числу мест, где нужно закрыть перебор. RequestOTP сначала проверяет rate-limit, и только если оба лимита не исчерпаны — генерирует код:
const (
otpRateLimitMin = 3 // запросов кода в минуту
otpRateLimitHour = 10 // запросов кода в час
maxOTPAttempts = 5 // попыток ввода кода на один pending-код
otpTTL = 5 * time.Minute
)
Два независимых счётчика (auth:rl:{email}:min, auth:rl:{email}:hour) защищают именно этап запроса кода — иначе злоумышленник, знающий чужой email, мог бы засыпать почту кодами. Пройдя оба лимита, RequestOTP пишет pending-запись:
key := "auth:" + email
pipe := rdb.TxPipeline()
pipe.HSet(ctx, key, map[string]interface{}{
"token": tok,
"code": code,
"attempts": 0,
})
pipe.Exec(ctx)
rdb.HExpire(ctx, key, otpTTL, "token", "code", "attempts")
Здесь есть намеренное отличие от второй статьи: вместо EXPIRE на весь ключ используется HExpire — per-field TTL для конкретных полей текущей pending-попытки. Разница проявляется, если ключ auth:{email} когда-нибудь переиспользуется смежной операцией: TTL относится именно к полям, записанным сейчас, а не ко всему ключу целиком. Повторный вызов RequestOTP для того же email просто перезаписывает pending-запись — старый код перестаёт быть валидным без явного удаления.
Второй рубеж — сама проверка кода. VerifyOTP считает попытки только когда pending-запись реально существует и код не совпал — отсутствие записи (код не запрашивался или истёк) не расходует лимит попыток, это осознанно другой класс ошибки:
attempts, _ := strconv.Atoi(data["attempts"])
if attempts >= maxOTPAttempts {
rdb.Del(ctx, key)
return 0, ErrTooManyAttempts
}
if !token.SafeEqual(code, data["code"]) {
rdb.HIncrBy(ctx, key, "attempts", 1)
return 0, ErrInvalidCode
}
rdb.Del(ctx, key)
return accountIDFromEmail(email), nil
token.SafeEqual — сравнение кода с постоянным временем выполнения, не обычное == (иначе код можно было бы восстанавливать по разнице во времени ответа символ за символом). Пять неверных попыток — и pending-запись удаляется целиком, ErrTooManyAttempts вместо ErrInvalidCode: пользователю нужно запрашивать код заново, а не продолжать подбор к старому. Обратная сторона: rate-limit собран как «прочитать счётчик → инкрементировать отдельной командой», не атомарной операцией — под настоящей конкурентной нагрузкой возможен небольшой overshoot лимита (в проде это INCR + EXPIRE NX или Lua-скрипт).
Успешная проверка кода возвращает accountID, детерминированно вычисленный из email (sha256(email), первые 8 байт). Это demo-заглушка вместо похода в таблицу пользователей — в реальной системе здесь был бы lookup/insert по email; подробнее об этом решении — в разделе про регистрацию ниже.
OAuth поверх mock-OIDC: state, PKCE, callback
Стенд не ходит во внешний Google или VK — вместо этого поднимает собственный учебный OIDC-провайдер (mockidp, встроен в тот же процесс на /mockidp) и проходит с ним полноценный authorization code flow с PKCE. Это осознанный выбор: поведение authorization code + PKCE не зависит от того, кто именно issuer, а зависимость от живого внешнего провайдера сделала бы стенд хрупким и недетерминированным.
Старт флоу генерирует state (защита от CSRF на callback) и PKCE-пару verifier/challenge (метод S256):
state, _ := token.OpaqueHex(16)
verifier, _ := token.OpaqueHex(32)
challenge := pkceChallengeS256(verifier) // base64url(sha256(verifier))
stateKey := "oauth:state:" + state
stateVal := provider + "|" + verifier
h.rdb.Set(ctx, stateKey, stateVal, oauthStateTTL) // 5 минут
// редирект на {issuer}/authorize?client_id&redirect_uri&state&
// code_challenge&code_challenge_method=S256
Важная деталь — куда именно кладётся verifier: не в cookie клиента, а в саму Redis-запись oauth:state:{state}, вместе со state. На callback оба значения нужны разом (state — чтобы отличить настоящий обратный вызов от подделки, verifier — чтобы предъявить его провайдеру при обмене кода), и хранить их одной атомарной записью проще, чем синхронизировать состояние между двумя разными носителями.
Callback читает эту запись через GetDel — не Get, именно GetDel:
val, err := h.rdb.GetDel(ctx, "oauth:state:"+state).Result()
if errors.Is(err, redis.Nil) {
return "", ErrOAuthBadState
}
state — одноразовый по конструкции: GetDel читает и удаляет за одну атомарную операцию, поэтому повторный callback с тем же state (например, если пользователь дважды кликнул «назад» в браузере или атакующий пытается переиграть перехваченный callback-URL) провалится точно так же, как подделанный или истёкший — записи уже нет. Дальше — обмен кода на токены (POST {issuer}/token с code+code_verifier) и запрос профиля (GET {issuer}/userinfo), оба — обычные HTTP-запросы к mockidp по TLS-эквивалентному локальному каналу демо.
Как и в OTP, аккаунт получается детерминированной хэш-функцией — на этот раз от provider+sub:
sum := sha256.Sum256([]byte(provider + ":" + p.Sub))
id := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
Здесь стоит явно проговорить два компромисса, честно перечисленных в README стенда и не решённых внутри демо:
stateзащищает от подделки и replay, но не от login-CSRF/fixation целиком. Он не привязан к браузеру, который инициировал запрос — нет double-submit cookie с тем же значением, сверяемой на callback. Классическая атака здесь: злоумышленник инициирует OAuth-флоу своим аккаунтом, получает валидный (не подделанный) authorization-код и подсовывает callback-URL жертве — её браузер до потери state ничем не отличается от легитимного. В проде это закрывается дополнительной state-cookie.mockidpредиректит наredirect_uriбез whitelisting — классический open redirect: провайдер принимаетredirect_uriиз запроса как есть, не сверяя его со списком зарегистрированных для клиента адресов. Приемлемо для учебного провайдера ровно потому, что учебный; реальный IdP обязан отклонять незарегистрированныйredirect_uri.
mockidp подписывает id_token RS256-ключом и публикует его через JWKS — это уже материал шестой статьи про OAuth2/OIDC вглубь: там же — почему клиент обязан проверять именно RS256 и отклонять none/HS256 (classic alg-confusion), device flow и client_credentials.
Telegram: canonical widget-HMAC против небезопасного OAuth-варианта
Стенд реализует Telegram-вход двумя способами специально — не потому что оба нужны в проде, а чтобы контраст был виден в одном месте.
Canonical-путь — Telegram Login Widget с HMAC-проверкой. Виджет подписывает данные пользователя на стороне Telegram и передаёт их браузеру; сервер обязан проверить эту подпись сам, прежде чем доверять содержимому:
secret := sha256.Sum256([]byte(botToken))
// checkString: все пары key=value из data, КРОМЕ hash,
// отсортированные по key, склеенные через "\n"
mac := hmac.New(sha256.New, secret[:])
mac.Write([]byte(checkString))
computed := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(computed), []byte(strings.ToLower(hash))) {
return false, nil
}
hmac.Equal, а не == — та же логика, что и token.SafeEqual у OTP: сравнение подписи должно быть constant-time. Но одной проверки подписи недостаточно: подписанные данные валидны навсегда, если не добавить собственную проверку срока. Поэтому вторым шагом идёт проверка auth_date:
if time.Since(time.Unix(authDateUnix, 0)) > maxAge { // DefaultTelegramAuthMaxAge = 24h
return false, nil
}
Без этой проверки корректно подписанный (не подделанный!) payload, однажды перехваченный, можно было бы предъявлять сколько угодно раз позже — replay валидной подписи, не требующий вообще ничего ломать. Freshness-проверка закрывает именно этот сценарий, а не подделку данных как таковую (от неё защищает HMAC).
Контрастный путь — Telegram через OAuth-подобный id_token. Стенд специально показывает, на что похоже, если пойти другим путём: получить от клиента id_token (по форме — JWT) и просто распаковать его payload, не проверяя подпись:
// parseTelegramOAuthProfile — КОНТРАСТНЫЙ вариант: декодирует payload
// JWT (id_token) БЕЗ проверки подписи.
parts := strings.Split(idToken, ".")
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
json.Unmarshal(payload, &profile)
Это не опечатка и не недосмотр — комментарий в коде прямо называет вариант небезопасным вне доверенного TLS-канала получения токена и существующим в стенде только ради сравнения с canonical widget-HMAC. Если токен пришёл не напрямую от провайдера по проверенному каналу, а откуда угодно ещё (например, клиент просто прислал id_token, который сам где-то взял), сервер в этом варианте примет любые претензии на личность без единой криптографической проверки. README стенда формулирует это прямо: оставлен «как контраст — на что это похоже, если сделать не тем путём, не как рекомендуемая практика». Практический вывод: если провайдер (Telegram или любой другой) предлагает готовый widget/SDK с проверяемой подписью — используйте его, а не самостоятельную распаковку токена без валидации.
Оба Telegram-пути сходятся к одной и той же функции выпуска аккаунта — accountIDFromTelegramID, детерминированно от "tg:"+tgID, тем же паттерном, что и у OTP/OAuth.
Регистрация: авто-создание аккаунта при первом входе
В стенде нет отдельного шага «зарегистрироваться» — есть только «войти», и вход при первом обращении становится регистрацией (just-in-time provisioning). Ни у OTP, ни у OAuth, ни у Telegram нет отдельной ветки «а если это новый пользователь» — VerifyOTP, callbackOAuth и issueForTelegram всегда одинаково детерминированно вычисляют accountID из метода-специфичного идентификатора (email, provider+sub, tg:{id}) и вызывают CreateTokenPair — вне зависимости от того, входит ли этот человек впервые или в сотый раз.
Технически это возможно потому, что стенд не хранит таблицу пользователей вовсе: accountID — не первичный ключ строки в БД, а чистая функция от входных данных метода. В реальной системе на этом месте был бы lookup/insert: «есть ли уже аккаунт с этим email / этим (provider, sub) / этим Telegram ID — если нет, создать». Демо сознательно срезает этот шаг (тот же приём, что и в третьей статье с authservice) — не потому что lookup не нужен, а чтобы не тащить в учебный стенд ещё одну БД ради единственной операции upsert.
Почему методы не сливаются в один аккаунт
Здесь стоит остановиться и явно проговорить то, что легко прочитать неправильно, глядя на код по диагонали: OTP, OAuth и Telegram в этом стенде дают разные accountID для одного и того же реального человека, даже если он использует один и тот же email во всех трёх местах. Три разные хэш-функции — sha256(email), sha256(provider+":"+sub), sha256("tg:"+tgID) — по построению не могут дать одно значение на разных входах.
Это не забытая фича, а осознанный fail-safe. Наивная альтернатива — сводить аккаунт по email, полученному из любого метода, — выглядит удобной, но опасна: OAuth-профиль или Telegram-виджет присылают email, который сервер не проверял независимо (это не тот email, на который сервер сам отправил и подтвердил код). Автоматически слить по нему новый вход с существующим аккаунтом — значит довериться непроверенному полю от клиента как ключу к чужой учётной записи. Как это честно зафиксировано в README стенда:
Демо намеренно даёт им разные account-ID: это fail-safe против неявного авто-merge профилей разных методов на один аккаунт по email/id без явного шага привязки — реальное объединение методов требует отдельного flow привязки, вне рамок стенда.
Другими словами: объединение методов входа на одну учётную запись — реальная и полезная задача (тот самый «вошёл вчера по email, сегодня через Telegram»), но она требует отдельного, осознанного шага привязки — обычно: пользователь уже залогинен одним методом, явно инициирует «привязать ещё один способ входа», и только тогда сервер связывает два внешних идентификатора с одним accountID. Такого flow в этом стенде нет — если он вам нужен, это отдельная задача поверх того, что здесь показано, а не то, что уже работает из коробки.
По той же причине в стенде нет и account switching — переключения между несколькими аккаунтами в одном браузере через sel:-механизм. Он упоминался в дизайне исходных приватных проектов, из которых собран стенд, но за рамки демо не выносился и в коде отсутствует; относитесь к нему как к возможному архитектурному расширению, а не как к части того, что здесь можно потрогать.
Куда дальше по серии
Эта статья замыкает вход и разные способы им пройти на модель токенов и сессий из первых трёх статей серии:
- Ст.1 — модели сессий: opaque vs JWT, куда в итоге ведут все методы входа этой статьи.
- Ст.2 — простой путь: opaque-сессии в Redis, тот же email-код, но без OAuth/Telegram.
- Ст.3 — сложный путь: JWT + refresh, тот же
session.CreateTokenPair, которым пользуется эта статья. - Ст.5 — refresh на клиенте: что происходит с выпущенным здесь refresh-токеном дальше — гонки вкладок и устройств, финал ядра серии.
Дальше — углубления поверх ядра:
- Ст.6 — OAuth2/OIDC вглубь: тот же
mockidp, но подробно — RS256/JWKS-валидация id_token, device authorization flow, client credentials, token introspection. - Ст.7 — MFA и TOTP: второй фактор поверх любого из методов входа этой статьи.
- Ст.8 — Passkeys и WebAuthn: ещё один, passwordless-способ входа — без кода, без пароля, без внешнего провайдера.
Источники
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- OWASP — Authentication Cheat Sheet
- OWASP — Forgot Password Cheat Sheet (те же принципы применимы к одноразовым кодам входа)
- Telegram — Login Widget
- Живой стенд статьи:
security/auth/login-methods
Комментарии