voidlearn
 

10.3Аутентификация и авторизация

Раздели authentication - кто обращается - и authorization - что ему разрешено. Сопоставь сессии, access/refresh tokens и JWT по отзыву, хранению и проверке, а затем привяжи каждый маршрут к нужной проверке.

Аутентификация, авторизация и всё, что рядом

Вся глава крутится вокруг одного слова. Токен - строка, которую сервер один раз выдал клиенту после проверки пароля, а клиент потом прикладывает к каждому запросу вместо пароля. Всё. После логина пароль больше никуда не летит, летит токен. Схемы различаются ответами на два вопроса. Что внутри строки: случайный идентификатор («непрозрачный» токен, его смысл знает только сервер) или подписанные данные, которые сервер проверит, ни у кого не спрашивая (это JWT)? И кто прикладывает токен к запросу: браузер сам (кука) или наш JavaScript руками (заголовок Authorization)? Из второго вопроса вырастает вся тема CSRF в главе 10.4.

ТерминВопрос, на который отвечаетПример механизмаКод ошибки
ИдентификацияКем ты называешься?логин, email, client_id, номер карты-
Аутентификация (AuthN)Ты действительно он?пароль, TOTP, подпись токена, mTLS-сертификат401 Unauthorized
Авторизация (AuthZ)Что тебе можно?роли, права, ACL, политики, владение объектом403 Forbidden
Аудит (Accounting)Кто что сделал?журнал действий, immutable-лог-

Порядок жёсткий: сначала аутентификация, потом авторизация. Нельзя решить, что человеку можно, пока не знаешь, кто он. Названия кодов в HTTP исторически перепутаны: 401 Unauthorized на деле означает «не аутентифицирован» и обязан идти с заголовком WWW-Authenticate, а 403 Forbidden значит «аутентифицирован, но прав не хватает».

Когда 401, а когда 403
  • Токена нет, он битый или протух → 401 + WWW-Authenticate: Bearer (клиент понимает, что надо обновить токен и повторить).
  • Токен валиден, но прав нет → 403. Повторять бессмысленно.
  • Токен валиден, прав нет, а само существование объекта надо скрыть → 404. Тогда перебором не узнать, какие id существуют (так GitHub прячет приватные репозитории).

Сессии против JWT: где живёт состояние

Разница одна, и всё остальное следует из неё. У сессии состояние лежит на сервере: клиент носит непрозрачный идентификатор, сервер по нему достаёт данные. У JWT состояние у клиента: все данные внутри токена, сервер только проверяет подпись.

Сессии - состояние на сервере Браузер Cookie: sid=8f3a… каждый запрос Инстанс API любой из N lookup Redis / таблица сессий sid → {uid, roles, exp} + отзыв мгновенный: DEL sid - и юзер вышел + данные всегда свежие, размер куки ~30 байт − поход в общий стор на КАЖДЫЙ запрос − стор сессий = единая точка отказа и узкое место JWT - состояние у клиента Клиент Bearer eyJhbGci… каждый запрос Инстанс API любой из N verify ключ в памяти процесса HMAC-секрет или публичный ключ + ноль обращений в стор, инстансы независимы + масштабируется горизонтально без общей БД − отозвать до exp нельзя: токен валиден, пока жив − 300–800 байт в каждом запросе, данные протухают Одна фраза, которая закрывает вопрос Сессия - ссылка на состояние, JWT - само состояние. Отсюда вся разница: цена проверки, возможность отзыва, размер, свежесть данных.
Где живёт состояние. Остальное вытекает отсюда. Нужен мгновенный отзыв, значит, нужен и стор; без стора придётся мириться с тем, что токен живёт до exp.
КритерийСессии (cookie + стор)JWT (Bearer)
Где состояниена сервере, клиент носит непрозрачный idвнутри токена у клиента
Проверка запросачтение из Redis/БД - сеть, ~0.2–1 мспроверка подписи в памяти - микросекунды
Отзывмгновенный: удалить записьневозможен без доп. механизма (список отзыва, версия токена)
Смена правприменяется со следующего запросатолько после протухания токена
Масштабированиенужен общий стор (или sticky sessions - хуже)инстансы полностью независимы
Размер на запрос~30–50 байт300–1500 байт, растёт с claims; попадает в каждый запрос и в логи
Кросс-доменностьсложно (кука привязана к домену, SameSite)легко - заголовок Authorization
Мобильные и сервис-к-сервисунеудобноестественно
Основной рисккража куки, CSRF, компрометация сторакража токена (XSS), утечка ключа подписи, невозможность отозвать
Когда братьмонолит, классический веб, админки, всё, где нужен мгновенный logoutмикросервисы, SPA + мобильные, API третьих лиц, федерация
Как отвечают сеньоры

«JWT нужен там, где проверяющей стороне нельзя ходить в общий стор: много сервисов, федерация, внешние клиенты. Обычному веб-приложению с одним бэкендом проще и дешевле сессия в Redis, и logout с ней мгновенный. Чаще всего ошибаются так: берут JWT ради моды, а потом прикручивают к нему blacklist в Redis с проверкой на каждый запрос. Получается та же сессия, только с токеном в килобайт и лишней криптографией.» В проде прижился компромисс: короткоживущий access-JWT (5–15 минут) + refresh-токен, хранимый на сервере. Проверка идёт быстро и без стора, а отзыв запаздывает не больше чем на TTL access-токена.

Устройство JWT

JWT (RFC 7519) состоит из трёх частей через точку: header.payload.signature. Каждая закодирована base64url (алфавит без + и /, без паддинга, чтобы спокойно жить в URL). Но кодирование ещё не шифрование: payload прочитает кто угодно.

header (base64url) eyJhbGciOiJIUzI1NiIsInR5… . payload / claims (base64url) eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6MTc… . signature dBjftJeZ4CVP-mB92K27uhbUJU… { "alg": "HS256", "typ": "JWT", "kid": "2026-01" } { "iss": "auth.example.com", "sub": "user-123", "aud": "api.example.com", "exp": 1767225600, "iat": 1767225000, "jti": "a1b2c3", "roles": ["editor"] } HMACSHA256( b64(header) + "." + b64(payload), secret) это ПОДПИСЬ, а не шифрование base64url - не шифрование Payload прочитает любой, кто перехватил токен: jwt.io декодирует его без всякого ключа. Подпись даёт целостность и авторство, но не конфиденциальность. Пароли, номера карт, персональные данные в claims класть нельзя - для тайны есть JWE.
Три части JWT. kid в заголовке указывает, каким ключом подписан токен, и благодаря ему ключи можно ротировать без даунтайма.

Стандартные claims, которые обязан назвать кандидат:

ClaimСмыслЧто будет, если не проверить
issкто выпустилпримешь токен от чужого issuer, если ключ случайно совпал или доверяешь нескольким
subкому выдан (id пользователя)-
audдля какого сервисаconfused deputy: токен, выданный для сервиса A, примет сервис B
expсрок годности (unix seconds)токен живёт вечно - главная дыра
nbfне действителен раньше-
iatкогда выпущеннельзя отсечь «токены старше момента X» при массовом отзыве
jtiуникальный id токенанечем адресовать токен в списке отзыва

HS256 против RS256

HS256 (HMAC-SHA256)RS256 / ES256 (асимметричные)
Ключиодин общий секретприватный подписывает, публичный проверяет
Кто может выпустить токенлюбой, у кого есть секрет - то есть все проверяющие сервисытолько владелец приватного ключа
Раздача ключейсекрет надо безопасно доставить всемпубличный ключ раздаётся открыто (JWKS-эндпоинт)
Скоростьочень быстроподпись дороже, проверка RSA быстрая; ES256 компактнее
Размер подписи32 байтаRSA-2048 - 256 байт; ECDSA P-256 - 64 байта
Когда братьмонолит или один сервис, который и выпускает, и проверяетмикросервисы, внешние потребители, OIDC-провайдер

Главный аргумент за RS256: принцип наименьших привилегий. С HS256 любой сервис, который проверяет токен, технически может его и подделать. Достаточно взломать один второстепенный сервис, чтобы выписать себе админский токен. С RS256 приватный ключ есть только у auth-сервиса, остальные тянут публичные ключи с /.well-known/jwks.json и кэшируют их по kid.

Классические атаки на JWT, о них спросят наверняка
  • alg: none. Атакующий меняет заголовок на {"alg":"none"}, выкидывает подпись и правит payload ("role":"admin"). Наивная библиотека «уважает» заголовок и говорит «подпись валидна, потому что её не требуется». Отсюда правило: алгоритм задаёт сервер, а не токен.
  • Подмена алгоритма RS256 → HS256. Сервер ждёт RSA и передаёт в проверку публичный ключ. Атакующий подписывает токен HMAC-ом, взяв публичный ключ как секрет: он же общедоступен. Если код слепо берёт alg из токена, подпись сойдётся. Защищает жёсткий whitelist допустимых алгоритмов.
  • Непроверенный exp делает токен вечным. Многие библиотеки проверяют exp, только если он есть, так что токен без этого claim тоже дыра.
  • Непроверенные aud/iss: токен соседнего сервиса или чужого тенанта примут как свой.
  • Подмена kid: если kid подставляют в путь к файлу ключа или в SQL-запрос, получается path traversal или инъекция. kid такой же недоверенный ввод, ключ по нему ищут только в whitelist.
  • Словарное слово вместо секрета. HS256 с секретом secret ломается hashcat за секунды. Нужно минимум 32 случайных байта.
// Правильная проверка на golang-jwt/jwt/v5
type Claims struct {
    Roles []string `json:"roles"`
    jwt.RegisteredClaims
}

var claims Claims
token, err := jwt.ParseWithClaims(raw, &claims,
    func(t *jwt.Token) (any, error) {
        // 1. семейство алгоритмов фиксируем сами
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
        }
        // 2. kid ищем только по whitelist, никаких путей и SQL
        kid, _ := t.Header["kid"].(string)
        key, ok := keyring[kid]
        if !ok {
            return nil, errors.New("неизвестный kid")
        }
        return key, nil
    },
    jwt.WithValidMethods([]string{"HS256"}), // 3. дублируем whitelist явно
    jwt.WithIssuer("auth.example.com"),      // 4. iss
    jwt.WithAudience("api.example.com"),     // 5. aud
    jwt.WithExpirationRequired(),            // 6. exp обязателен
    jwt.WithLeeway(30*time.Second),          // допуск на расхождение часов
)
if err != nil || !token.Valid {
    problem(w, http.StatusUnauthorized, "невалидный токен")
    return
}

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

Вопрос-ловушка: «правильного» варианта нет, ты выбираешь, от какой атаки страдать.

localStorage / sessionStoragehttpOnly + Secure + SameSite cookie
Доступ из JSесть - любой XSS крадёт токеннет: document.cookie её не видит
Уязвим к CSRFнет: заголовок надо проставить рукамида: браузер сам шлёт куку - нужен SameSite и/или CSRF-токен
Отправкавручную: Authorization: Bearerавтоматически браузером
Кросс-доменпросто, работает и в мобильном приложениисложно: SameSite=None; Secure + CORS с credentials
Утечка в логизаголовок часто пишут в access-логи и трассировкукука тоже может попасть в логи, но реже
Как отвечать

«Из localStorage токен достанет любой JS: одна XSS-дыра, и токен уехал атакующему, причём навсегда, потому что отозвать JWT нечем. Куку с HttpOnly из JS не прочитать, поэтому XSS не сможет её украсть. Зато сможет слать запросы от имени пользователя прямо из его браузера, так что XSS остаётся катастрофой в любом случае. Практический дефолт для веба: Set-Cookie: token=…; HttpOnly; Secure; SameSite=Lax; Path=/ плюс CSRF-защита. SPA на другом домене и мобильным подходит Authorization: Bearer, а токен держим в памяти JS (переменная в замыкании), refresh кладём в httpOnly-куку с Path=/auth/refresh. Тогда XSS не достанет ни долгоживущий refresh, ни токен после перезагрузки страницы.»

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    token,
    Path:     "/",
    HttpOnly: true,                    // недоступна из JS
    Secure:   true,                    // только по HTTPS
    SameSite: http.SameSiteLaxMode,    // база защиты от CSRF
    MaxAge:   int((15 * time.Minute).Seconds()),
    // Domain намеренно не задаём: без него кука привязана к конкретному хосту
    // и не утечёт на поддомены (__Host- префикс дал бы то же самое жёстче)
})

Refresh-токены: зачем, ротация, обнаружение кражи

Дилемма такая: с длинным TTL access-токена удобно, но украденный токен долго живёт; короткий TTL безопасен, но заставляет логиниться каждые 10 минут. Поэтому выдают пару токенов:

  • Access - JWT, 5–15 минут, живёт в памяти клиента, летит в каждый запрос, проверяется без похода в БД.
  • Refresh - непрозрачная случайная строка (не JWT), 7–30 дней, хранится в БД в хешированном виде, летит только на /auth/refresh, привязан к устройству и сессии.

Refresh специально делают непрозрачным: его всё равно сверяют с БД, так что подпись не нужна, а отозвать его можно одной строкой UPDATE. В БД кладут хеш (хватит SHA-256: энтропии 256 бит, словарём не подберёшь), чтобы дамп базы не давал готовых сессий.

Ротация и обнаружение переиспользования

При каждом обновлении старый refresh инвалидируется, а клиент получает новый. Заодно выходит бесплатный детектор кражи: если кто-то предъявил уже использованный refresh, значит, токен ходит по рукам. Кто из двоих вор, не понять, поэтому убивают всё семейство токенов и заставляют залогиниться заново.

CREATE TABLE refresh_tokens (
    id          uuid PRIMARY KEY,
    family_id   uuid NOT NULL,          -- цепочка ротаций одной сессии
    user_id     bigint NOT NULL,
    token_hash  bytea NOT NULL UNIQUE,  -- sha256(секрет), не сам секрет
    used_at     timestamptz,            -- NOT NULL => уже потрачен
    revoked_at  timestamptz,
    expires_at  timestamptz NOT NULL,
    user_agent  text,
    ip          inet
);
CREATE INDEX ON refresh_tokens (family_id) WHERE revoked_at IS NULL;
func (s *Auth) Refresh(ctx context.Context, raw string) (Pair, error) {
    h := sha256.Sum256([]byte(raw))

    tx, _ := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    defer tx.Rollback()

    var t token
    // FOR UPDATE: параллельный refresh ждёт блокировку и потом видит used_at.
    // Без неё оба выпустят по живой паре, и кражу никто не заметит
    err := tx.QueryRowContext(ctx,
        `SELECT id, family_id, user_id, used_at, revoked_at, expires_at
           FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, h[:]).Scan(...)
    if errors.Is(err, sql.ErrNoRows) {
        return Pair{}, ErrUnauthorized
    }

    switch {
    case t.RevokedAt.Valid || t.ExpiresAt.Before(time.Now()):
        return Pair{}, ErrUnauthorized
    case t.UsedAt.Valid:
        // переиспользование: токен уже меняли, значит, утёк. Рубим всё семейство
        _, _ = tx.ExecContext(ctx,
            `UPDATE refresh_tokens SET revoked_at = now()
              WHERE family_id = $1 AND revoked_at IS NULL`, t.FamilyID)
        _ = tx.Commit()
        s.alert(ctx, "refresh token reuse", t.UserID, t.FamilyID)
        return Pair{}, ErrUnauthorized
    }

    tx.ExecContext(ctx, `UPDATE refresh_tokens SET used_at = now() WHERE id = $1`, t.ID)
    newRaw, newHash := generateToken()  // 32 байта из crypto/rand
    tx.ExecContext(ctx, `INSERT INTO refresh_tokens
        (id, family_id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`,
        uuid.NewV7(), t.FamilyID, t.UserID, newHash, time.Now().Add(30*24*time.Hour))
        // v7 сортируется по времени: вставки ложатся в хвост индекса, а не разбрасываются

    if err := tx.Commit(); err != nil { return Pair{}, err }
    return Pair{Access: s.issueJWT(t.UserID), Refresh: newRaw}, nil
}
Глубже: гонка при ротации

В продакшене это настоящая боль. Мобильное приложение шлёт три запроса разом, все три получают 401 и все три идут обновлять токен одним и тем же refresh. Один выигрывает, двое предъявляют «уже использованный» токен, и система честно разлогинивает пользователя, который ничего плохого не делал. Лечат тремя способами, обычно всеми сразу: (1) на клиенте ставят единый мьютекс/промис на обновление, остальные ждут его результата; (2) на сервере дают grace-период: использованный токен ещё 10–30 секунд валиден и отдаёт тот же новый токен (идемпотентный refresh); (3) берут FOR UPDATE или уникальный индекс, чтобы гонка внутри БД разрешалась детерминированно. Назовёшь эту проблему сам, и станет ясно, что ты с этим действительно работал.

Как разлогинить пользователя с JWT

Если коротко, по-настоящему никак. Подписанный токен валиден, пока не истёк exp, и проверяющий сервер не обязан ни с кем сверяться. Остаётся выбрать компромисс.

ПриёмКак работаетЦенаЗадержка отзыва
Короткий TTL + refreshaccess живёт 5–15 мин; при logout удаляем refresh - обновиться уже не выйдетноль, это и так нужнодо конца TTL access
Blacklist по jtiRedis SET jti 1 EX ttl, проверка на каждый запроспоход в Redis на каждый запрос - теряется главный плюс JWTмгновенно
Версия токена (token_version)в claims кладём ver; в БД у юзера счётчик; logout/смена пароля увеличивают счётчикчтение версии (кэшируется в памяти на 5–30 с)мгновенно или до конца кэша
not_before по пользователюхраним «токены, выпущенные раньше T, недействительны»; сравниваем с iatто же, что версиямгновенно
Убить сессию на клиентестереть токен из хранилищанольне защищает: украденный токен продолжает работать
Что сказать на собесе

«Стандартный logout: удаляю refresh-токен и стираю access на клиенте, access доживает свои 5–15 минут. Если бизнесу нужен мгновенный отзыв (бан, кража аккаунта, смена пароля), добавляю token_version в claims и в БД. Версия кэшируется в памяти инстанса на десятки секунд, а инкремент убивает все токены пользователя разом. Полный blacklist по jti делаю только точечно, потому что он превращает stateless-схему в stateful.» Отдельно проговори, что смена пароля обязана инвалидировать все сессии: это требование, а не опция.

OAuth 2.0 и OIDC

OAuth 2.0 придуман для делегирования доступа, а не для логина: «дай этому приложению читать мои репозитории, не отдавая ему пароль от GitHub». OIDC надстраивается над OAuth тонким слоем и добавляет к нему аутентификацию: новый скоуп openid, дополнительный id_token (JWT с данными о пользователе), эндпоинт /userinfo и дискавери /.well-known/openid-configuration. Фраза «OAuth - авторизация, OIDC - аутентификация поверх неё» закрывает половину вопроса.

РольКто этоПример
Resource Ownerпользователь, владелец данныхчеловек с аккаунтом
Clientприложение, которое хочет доступнаш веб-сервис, SPA, мобильное приложение
Authorization Serverвыдаёт токены, аутентифицирует пользователяKeycloak, Auth0, Яндекс ID, «Сбер ID»
Resource ServerAPI, которое принимает access-токеннаш API, GitHub API

Клиенты делятся на confidential (серверные, умеют хранить client_secret) и public (SPA и мобильные: секрет там спрятать негде, любой выковыряет его из бандла или apk). Для public-клиентов PKCE и придумали.

Пользователь / браузер Клиент (наше приложение) Authorization Server Resource API 1. verifier = случайные 43–128 символов challenge = base64url(SHA256(verifier)) 2. редирект на /authorize client_id · redirect_uri · scope · state · code_challenge · S256 3. GET /authorize 4. логин + экран согласия 5. 302 на redirect_uri?code=…&state=… 6. GET /callback 7. сверить state - иначе CSRF на логине 8. POST /token: code + code_verifier 9. SHA256(verifier) == challenge? 10. access_token + refresh_token (+ id_token) 11. Authorization: Bearer access_token Зачем PKCE Перехваченный код бесполезен без verifier: атакующий видел только его SHA256, а обменять код можно один раз. Изначально PKCE придумали для мобильных (кастомную схему редиректа может перехватить чужое приложение), но OAuth 2.1 требует его для ВСЕХ клиентов. Implicit flow (response_type=token) объявлен устаревшим: токен светился прямо в URL.
Authorization code + PKCE. Код летит через браузер, и его можно перехватить. Но меняют код на токен по прямому серверному каналу, и для обмена нужен code_verifier, которого у перехватчика нет.
Что обязательно назвать про этот flow
  • state защищает от CSRF на этапе логина: без него атакующий подсовывает свой code, и жертва «логинится» в аккаунт атакующего. Значение случайное, привязано к сессии, проверяется на колбэке. В OIDC ту же роль для id_token играет nonce.
  • redirect_uri сверяется на AS по точному совпадению с зарегистрированным. Wildcard и «начинается с» оставляют классическую дыру: через неё код уводят на чужой домен (open redirect + перехват кода).
  • Код одноразовый и короткоживущий (обычно 30–60 секунд). Если его предъявили повторно, выданные по нему токены надо отозвать.
  • Почему умер implicit flow: токен приходил прямо в URL-фрагменте и попадал в историю браузера, в Referer, в логи прокси, а refresh-токена не было вовсе. Его место занял code+PKCE.
  • Другие гранты: client_credentials для сервис-к-сервису (пользователя нет вообще), device_code для телевизоров и CLI. password (ROPC) устарел и запрещён в OAuth 2.1: клиент видит пароль пользователя.

API-ключи и Basic auth - где уместны

МеханизмКак выглядитГде уместенЧего не умеет
BasicAuthorization: Basic base64(user:pass)внутренние утилиты, healthcheck, прототипы - только поверх TLSнет срока жизни, пароль летит в каждом запросе, нет скоупов, легко утекает в логи
API-ключAuthorization: Bearer sk_live_… или X-API-Keyинтеграции сервис-к-сервису, вебхуки, партнёрские APIнет пользователя, нет TTL по умолчанию, нет делегирования
OAuth-токенAuthorization: Bearer eyJ…пользовательские сценарии, сторонние приложениясложнее в реализации
mTLSклиентский сертификатвнутренний трафик, service mesh, банковские интеграциинужен PKI и ротация сертификатов
HMAC-подпись запросаподпись тела + timestamp + nonceвебхуки и платёжные API (защищает и от подмены тела, и от replay)сложнее для клиента
Правила для API-ключей
  • Ключ берут из crypto/rand, 32 случайных байта, и добавляют префикс-метку (sk_live_, sk_test_): по префиксу секрет-сканеры ловят утечки в публичных репозиториях.
  • В БД лежит хеш, а не сам ключ; пользователь видит ключ один раз, при создании.
  • Ключ передают только в заголовке Authorization, никогда в query-параметре: URL оседает в логах nginx, в Referer и в истории браузера.
  • У ключа должны быть владелец, скоупы, срок жизни, метка «последнее использование» и кнопка отзыва. Ротируют с окном, в котором валидны оба ключа.
  • Сравнивают через subtle.ConstantTimeCompare или по хешам, но не == по сырой строке.

RBAC, ABAC и middleware авторизации

RBAC: у пользователя роли, у ролей - права, а проверка звучит как «есть ли у роли право posts:delete». Просто, понятно, легко аудируется, но плохо выражает «можно, если это твой пост, рабочее время и сумма меньше 10 000». В ABAC решение считается функцией от атрибутов субъекта, объекта, действия и среды. Гибко, но отлаживать труднее, и понять, «почему отказали», тоже.

RBACABAC
Модельuser → roles → permissionspolicy(subject, object, action, env) → allow/deny
Пример«редактор может удалять посты»«автор может удалять свой пост в первые 24 часа»
Плюсыпредсказуемость, простой аудит, легко объяснить бизнесувыражает любые правила, не плодит роли
Минусывзрыв числа ролей (editor_ru_premium_readonly)сложность, риск «почему нет доступа?» без внятного ответа
Инструментытаблицы в БД, CasbinOPA/Rego, Casbin, Cedar, SpiceDB (ReBAC)

В жизни почти всегда встречается гибрид: RBAC грубо фильтрует на входе (middleware), а владение и условия точечно проверяют в сервисном слое.

// Middleware: только грубая проверка «есть ли вообще такое право»
func RequirePerm(perm string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            u, ok := UserFrom(r.Context())
            if !ok {
                w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
                problem(w, http.StatusUnauthorized, "нужна аутентификация")
                return
            }
            if !u.Can(perm) {
                problem(w, http.StatusForbidden, "недостаточно прав")
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

mux.Handle("DELETE /v1/posts/{id}", RequirePerm("posts:delete")(deletePost))
Главная ошибка: авторизация только в middleware

Middleware знает про маршрут, но ничего не знает про объект. Он пропустит DELETE /v1/posts/777 от любого редактора, даже если пост чужой. Это IDOR / broken object level authorization, номер один в OWASP API Top 10. Владение и условия проверяют там, где объект уже загружен, то есть в сервисе, а лучше прямо в запросе: UPDATE posts SET … WHERE id = $1 AND author_id = $2. Ноль затронутых строк означает «нет прав или нет объекта». Второе правило, deny by default: новый роут без явного разрешения должен быть закрыт, а не открыт. Тест «а что если убрать декоратор» стоит автоматизировать: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика.

Хранение паролей

Пароль нельзя ни хранить в открытом виде, ни шифровать (расшифровать сможет и атакующий с ключом), ни хешировать быстрым алгоритмом. Нужна намеренно медленная функция с солью.

АлгоритмСтойкость к GPU/ASICПараметрыВердикт
MD5, SHA-1, SHA-256никакой: миллиарды хешей в секунду на одной видеокарте-нельзя - они спроектированы быстрыми
PBKDF2средняя (мало памяти → хорошо параллелится на GPU)600 000+ итераций HMAC-SHA256только если требует регулятор/FIPS
bcryptхорошаяcost 12 (2^12 итераций)надёжная классика, есть в stdlib-экосистеме
scryptхорошая (memory-hard)N=2^17, r=8, p=1ок
argon2idлучшая: memory-hard + защита от side-channelминимум OWASP: m=19 МиБ, t=2, p=1; практичнее m=64 МиБ, t=3, p=4дефолт для нового кода
// bcrypt: соль внутри самого хеша
hash, err := bcrypt.GenerateFromPassword(
    []byte(pass), 12)         // cost 12 ≈ 250 мс

err = bcrypt.CompareHashAndPassword(
    hash, []byte(pass))       // сравнение constant-time
// bcrypt учитывает только 72 байта пароля,
// а GenerateFromPassword на более длинном
// вернёт ErrPasswordTooLong. Длинную фразу
// заранее сворачивают в SHA-256 (или берут argon2)
// argon2id
salt := make([]byte, 16)
rand.Read(salt) // с Go 1.24 ошибки не бывает
key := argon2.IDKey([]byte(pass), salt,
    3,          // time: число проходов
    64*1024,    // memory: 64 МиБ
    4,          // threads
    32)         // длина ключа

ok := subtle.ConstantTimeCompare(key, stored) == 1
// в БД: соль + все параметры + хеш,
// иначе не сможешь их поднять при ротации
Соль, перец и timing
  • Соль своя у каждого пароля и лежит рядом с хешем открыто. Прятать пароль она не должна: соль ломает rainbow-таблицы и делает так, что два одинаковых пароля дают разные хеши (иначе видно, у кого пароль 123456).
  • Перец - общий секрет, который живёт не в БД, а в HSM/Vault или в конфиге приложения. Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши без перца бесполезны. Минус в том, что перец просто так не сменишь.
  • Сравнение за constant-time. Обычное == для строк идёт побайтово с ранним выходом и в теории позволяет подбирать значение байт за байтом. Для токенов и HMAC subtle.ConstantTimeCompare обязателен.
  • User enumeration. Если на несуществующий логин отвечать мгновенно, а на существующий через 250 мс, атакующий соберёт базу логинов по таймингу. Поэтому, когда пользователя нет, всё равно прогоняют фиктивный хеш и отвечают одинаковым «неверный логин или пароль».
  • Ротация параметров. Cost/params хранят вместе с хешем. Если при успешном логине параметры оказались устаревшими, хеш пересчитывают с новыми: только в этот момент у тебя на руках открытый пароль.
  • Rate limit на логин и блокировка после N неудач: без них против онлайн-перебора бесполезна любая медленная функция.

Секреты приложения

Под секретом понимают пароль БД, ключ подписи JWT, токен платёжного провайдера. Требований к ним три: не попасть в git, не светиться в логах и уметь меняться без релиза.

СпособПлюсыМинусы
Переменные окруженияпросто, поддерживают все платформы, 12-factorвидны в /proc/PID/environ и в дампах; часто утекают в логи краш-репортеров и в docker inspect; ротация = рестарт
Файл, смонтированный в контейнерможно обновлять без рестарта, права на файлнужно уметь перечитывать
Kubernetes Secretнативно, RBAC, монтируется как файл или envэто base64, а не шифрование; без включённого encryption-at-rest в etcd лежит почти открытым
Vault / облачный KMSцентрализованно, аудит, TTL, динамические учётки БД, ротацияещё одна зависимость в рантайме, нужна аутентификация самого приложения

Vault AppRole - стандартный способ аутентифицировать сервис без участия человека. У приложения есть role_id (не секрет, лежит в конфиге) и secret_id (одноразовый, короткоживущий, приходит отдельным каналом: от оркестратора или через response wrapping). Приложение меняет пару на токен с TTL, регулярно продлевает его (renew) и по этому токену читает секреты. role_id и secret_id разделены, чтобы утечка одного артефакта не давала доступа. Есть и отдельный джокер, динамические секреты: Vault сам создаёт временного пользователя в PostgreSQL с TTL 1 час, и «украсть пароль от БД» становится почти бессмысленно.

Что нельзя в git и что делать, если уже
  • Нельзя: .env с боевыми значениями, приватные ключи, kubeconfig, дампы с продовыми данными, docker-compose.yml с реальными паролями. В репозитории живёт только .env.example с пустышками.
  • Удалить файл новым коммитом недостаточно: секрет остался в истории и в форках. Порядок строгий: сначала отозвать и перевыпустить секрет, потом (по желанию) чистить историю git filter-repo/BFG. Ротация обязательна, чистка истории - косметика.
  • В CI ставят gitleaks/trufflehog на pre-commit и в пайплайн.
  • Секрет не должен попадать в логи: не логируй целиком конфиг и заголовки, дай секретным типам String(), который возвращает "[REDACTED]", а в структурированном логгере заведи список замаскированных полей.
  • Ротация должна быть рутиной, а не подвигом: держишь два активных ключа с kid, выкатываешь новый, переключаешь подпись, через окно снимаешь старый.

Вопросы

10
Суть: аутентификация отвечает на вопрос «ты действительно тот, кем назвался», авторизация - «что тебе можно делать». Первое даёт 401, второе - 403. Порядок всегда такой: сначала AuthN, потом AuthZ.

Разложить по полочкам

  • Идентификация: ты предъявляешь идентификатор (логин, email, client_id). Сама по себе она ничего не доказывает.
  • Аутентификация (AuthN) проверяет доказательство: пароль, одноразовый код, подпись JWT, клиентский сертификат mTLS. После неё «мы знаем, кто это».
  • Авторизация (AuthZ) - решение о доступе к конкретному ресурсу и действию: роль, право, политика, владение объектом. На выходе «можно/нельзя».
  • Аудит записывает, кто что сделал. Про него часто забывают, а на собесе в финтехе спрашивают отдельно.

Почему путаница живучая

Виноват сам HTTP: статус 401 называется Unauthorized, хотя означает «не аутентифицирован». По смыслу 401 говорит «предъяви (другие) учётные данные» и обязан нести заголовок WWW-Authenticate, а 403 значит «мы поняли, кто ты, и тебе нельзя»: перевыпуск токена тут не поможет.

Практическое следствие для кода

Это два разных слоя. Аутентификацию делает общий middleware на всё приложение: разбирает заголовок, проверяет подпись или сессию, кладёт subject в контекст. Авторизация живёт ближе к домену. Middleware закрывает грубые вещи (роль admin на /admin/*), а «этот заказ принадлежит этому пользователю» можно проверить только там, где известен заказ, то есть в сервисном слое.

На чём ловят

Вопрос-продолжение: «а 403 отдавать всегда?» Нет. Если само существование ресурса надо скрыть (приватный репозиторий, чужой платёж), правильнее отдать 404: 403 подтверждает, что объект есть, а это уже утечка информации. GitHub так и делает.

Суть: сессия - ссылка на состояние на сервере (мгновенный отзыв, но поход в хранилище на каждый запрос); JWT - самодостаточный подписанный факт (проверка локальная и дешёвая, но отозвать до истечения нельзя без того самого состояния, от которого убегали).

Сравнение по осям

ОсьСессия (cookie + стор)JWT
Где состояниена сервере: Redis/БДу клиента, в самом токене
Проверка запросачтение из стора (сеть ~1 мс)проверка подписи локально (микросекунды)
Отзывмгновенный: DEL session:IDневозможен без чёрного списка
Изменение правприменяется со следующего запросатолько после переиздания токена
Размер на запрос~30–60 байт0.5–2 КиБ в каждом запросе
Горизонтальное масштабированиенужен общий стор (не sticky sessions)инстансы независимы, нужен только публичный ключ
Межсервисностьсложно: у каждого сервиса нет доступа к сторусильная сторона: подпись проверяет кто угодно
SPOFлёг Redis - легли все логинынет зависимости на горячем пути

Главный аргумент, который ждут

Стоит приделать к JWT чёрный список отозванных токенов, и в Redis придётся ходить на каждый запрос. Возвращается ровно та зависимость, от которой JWT и должен был избавить. Поэтому формулировка такая: JWT покупает дешёвую распределённую проверку ценой окна, в котором токен не отозвать; окно равно TTL токена. Всё проектирование сводится к тому, чтобы окно было маленьким (5–15 минут) и закрывалось refresh-токеном, у которого состояние уже есть.

Как выбирать

  • Классическому веб-приложению с одним бэкендом и браузерными клиентами хватит сессий: проще, безопаснее, отзыв бесплатный. Это до сих пор дефолт, а не легаси.
  • Публичный API, мобильные клиенты, десяток сервисов, которым нужно проверять токен без общего стора - JWT (короткий access + серверный refresh).
  • В проде чаще всего побеждает гибрид: JWT-access на 10 минут для межсервисных проверок + серверная запись о сессии/refresh для отзыва и списка устройств.
Про «stateless» без розовых очков

Полностью stateless JWT-система не умеет разлогинить со всех устройств, показать «активные сессии», немедленно забанить пользователя, отозвать доступ при смене пароля, снизить права. Всё это продуктовые требования, они появляются на втором году жизни сервиса и возвращают состояние.

Суть: три base64url-части через точку, подпись считается по header.payload. HS256 - общий секрет (симметрично, годится внутри одного сервиса), RS256/ES256 - приватный ключ подписывает, публичный проверяет (для многих потребителей). Хранить лучше в httpOnly-куке с SameSite, TTL access-токена - минуты.

Структура

// eyJhbGciOi… . eyJzdWIiOi… . 4pcPyMD0…   (три части base64url через точку)
// header:    {"alg":"HS256","typ":"JWT","kid":"2024-06"}
// payload:   {"sub":"123","exp":1700000000,"iat":...,"iss":"api","aud":"web","jti":"..."}
// signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

Вслух обязательно скажи: base64 не шифрует. Payload читает любой, у кого есть токен, поэтому персональные данные, внутренние идентификаторы и суммы в токен не кладут. Подпись гарантирует целостность и авторство, а не конфиденциальность (для шифрования есть JWE, отдельная спецификация, в вебе она почти не встречается).

HS256 против RS256

HS256 (HMAC)RS256 / ES256
Ключиодин общий секретпара: приватный подписывает, публичный проверяет
Кто может подписатьвсе, кто может проверитьтолько владелец приватного ключа
Скоростьочень быстроподпись дороже, проверка RSA быстрая; ES256 компактнее
Раздача ключасекрет в каждый сервис - риск утечки растёт линейнопубличный ключ по JWKS-эндпоинту, кэшируется по kid
Когдамонолит, один эмитент и один потребительмного сервисов, внешние потребители, OIDC

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

  • localStorage доступен любому JS на странице, так что любой XSS = кража токена. Зато CSRF ему не страшен: браузер сам ничего оттуда не отправляет.
  • httpOnly; Secure; SameSite=Lax-куку JS не прочитает, и XSS её не украдёт. Но браузер прикрепляет её сам, поэтому нужна защита от CSRF (SameSite плюс, для критичных операций, токен).
  • Мобильное приложение - Keychain/Keystore, не SharedPreferences.
  • Продвинутый вариант для SPA: access-токен живёт только в памяти JS (в переменной, не в хранилище), refresh лежит в httpOnly-куке с узким Path=/auth/refresh. После перезагрузки вкладки срабатывает тихий refresh.

Срок жизни

Access живёт 5–15 минут, и это окно, в котором отозванный доступ ещё работает. Refresh живёт от нескольких дней до месяца, с ротацией. Обязательные поля: exp (иначе токен вечный), iat, iss и aud (иначе токен от соседнего сервиса примут как свой), а если нужен поштучный отзыв, ещё и jti. alg проверяют по белому списку своих алгоритмов, а не берут из заголовка токена, иначе открываются классические атаки alg: none и подмена RS256 на HS256 с публичным ключом в роли секрета.

Суть: refresh существует, чтобы access мог быть коротким. Access ходит в каждом запросе и потому светится везде; refresh лежит в одном месте, уходит только на /auth/refresh, хранится на сервере - и потому его можно отозвать.

Зачем разделение

Будь токен один, пришлось бы выбирать между удобством (долгий TTL, пользователя не разлогинивает) и безопасностью (короткий TTL, украденный токен быстро протухает). Два токена снимают этот компромисс: короткий access ограничивает ущерб от утечки, долгий refresh держит сессию. К тому же на refresh сервер обязательно идёт в своё хранилище и заодно может проверить, не забанен ли пользователь, не сменился ли пароль, не отозвано ли устройство.

Ротация (rotation)

На каждый обмен выдаётся новый refresh, старый помечается использованным. Токен становится одноразовым, и «украл один раз - пользуюсь вечно» перестаёт работать.

// обмен уже потраченного refresh = сигнал кражи
row, err := repo.GetRefresh(ctx, hash(presented))
switch {
case err != nil:
    return ErrInvalid
case row.UsedAt != nil:                 // токен уже обменивали
    repo.RevokeFamily(ctx, row.FamilyID) // рвём всю цепочку: и вора, и жертву
    audit.Warn("refresh reuse", "user", row.UserID)
    return ErrInvalid
case time.Now().After(row.ExpiresAt):
    return ErrExpired
}
newRT := random256()
repo.MarkUsedAndIssue(ctx, row.ID, hash(newRT), row.FamilyID)

Reuse detection

Идея взята из RFC 6819 и BCP по OAuth: у всех токенов одной сессии общий family_id. Если предъявлен уже использованный refresh, значит, копия токена есть у двоих: у вора и у жертвы. Кто из них пришёл, серверу неизвестно, поэтому убивают всю семью: настоящий пользователь просто перелогинится, а вор потеряет доступ. Так кражу ловят дёшево, без хитрого фингерпринтинга.

Отзыв и хранение

  • Refresh хранят хешированным (SHA-256), и утёкший дамп таблицы не даёт рабочих токенов.
  • Строка в БД: user_id, family_id, token_hash, expires_at, used_at, revoked_at, user_agent, ip, device_name. Из неё же вырастает экран «активные сессии».
  • Для отзыва проставляют revoked_at одной записи, семье или всем записям пользователя. Отсюда три кнопки: «выйти», «выйти на этом устройстве», «выйти везде».
  • Смена пароля обязана отзывать все refresh (кроме, по вкусу, текущей сессии).
  • Refresh кладут в куку httpOnly; Secure; SameSite=Strict; Path=/auth/refresh: браузер не потащит его в обычные запросы, а XSS не прочитает.
Гонка при ротации

Мобильное приложение шлёт пять параллельных запросов, все получают 401 и все идут рефрешить одним и тем же токеном. Один выигрывает, четверо натыкаются на «reuse» и выкидывают пользователя. На клиенте спасает single-flight (один рефреш, остальные ждут его результат), на сервере - grace-окно 10–30 секунд: повторный обмен тем же токеном в этом окне возвращает тот же новый токен из журнала и кражей не считается.

Суть: подписанный токен нельзя «отменить» - он валиден до exp по построению. Значит логаут - это либо удаление токена у клиента плюс отзыв refresh (окно = TTL access), либо явный чёрный список, за который платим походом в Redis на каждый запрос.

Варианты по возрастанию цены

  1. Просто выбросить токен на клиенте + отозвать refresh на сервере. Access живёт свои 5–15 минут. Для 95 % продуктов этого достаточно, и такой ответ вполне годится.
  2. Чёрный список по jti: SET revoked:jti 1 EX ttl_остаток. Ключ исчезнет сам, когда истечёт токен, поэтому список остаётся маленьким. Платишь за это GET в Redis на каждый запрос.
  3. Версия токенов пользователя (token_version/epoch): в payload кладём номер, в БД держим текущий. «Выйти везде» = инкремент. Проверка сводится к сравнению чисел и кэшируется в памяти инстанса с TTL 30 с. Это на порядок дешевле чёрного списка и покрывает главный сценарий.
  4. Не выдавать access вообще и вернуться к сессиям. Иногда это правильный ответ.
// вариант 3: одна цифра решает 90% задач отзыва
type Claims struct {
    jwt.RegisteredClaims
    Ver int `json:"ver"`
}
// при логауте-везде / смене пароля / бане:
//   UPDATE users SET token_version = token_version + 1 WHERE id = $1
func (a *Auth) check(ctx context.Context, c Claims) error {
    cur, err := a.verCache.Get(ctx, c.Subject) // локальный кэш на 30 секунд
    if err != nil { return err }
    if c.Ver != cur { return ErrRevoked }
    return nil
}

Что сказать, чтобы прозвучало зрело

«Логаут с JWT упирается не в технику, а в решение, какое окно рассинхронизации мы готовы терпеть. Ноль секунд стоит запроса в стор на каждый вызов, то есть возврата к состоянию. Пять минут бесплатны». Дальше отдельно проговори сценарии, где нужен именно ноль: увольнение сотрудника, компрометация устройства, бан за фрод, отзыв прав в админке. Для них обычно делают гибрид: короткий TTL для всех + чёрный список только для критичных ролей или только для эндпоинтов с деньгами.

Мелочи логаута, о которых забывают

Удалить куку правильно (Max-Age=0 и те же Path/Domain, иначе кука останется), отозвать refresh-семью, закрыть открытые WebSocket-соединения (они аутентифицированы один раз при handshake и живут часами), инвалидировать серверные кэши прав.

Суть: OAuth 2.0 - протокол делегирования доступа: приложение получает ограниченный токен к чужому API, не видя пароля пользователя. OIDC - тонкая надстройка над ним, которая добавляет id_token и превращает всё это в аутентификацию («войти через Google»).

Четыре роли

  • Resource Owner - пользователь, владелец данных.
  • Client - приложение, которое хочет доступ (твой бэкенд или SPA).
  • Authorization Server выдаёт токены (Google, Keycloak, Auth0).
  • Resource Server - API, которое проверяет токен и отдаёт данные.

Authorization code flow + PKCE

  1. Клиент редиректит браузер на /authorize с response_type=code, client_id, redirect_uri, scope, state и code_challenge.
  2. Пользователь логинится на стороне провайдера и подтверждает согласие. Твоё приложение пароля не видит, в этом и весь смысл.
  3. Провайдер редиректит обратно на redirect_uri?code=...&state=.... Одноразовый код живёт секунды.
  4. Бэкенд меняет код на токены запросом server-to-server на /token, предъявляя client_secret (или code_verifier для публичных клиентов).
  5. Получает access_token, refresh_token и, в случае OIDC, id_token.

Почему через код, а не сразу токен? Код летит через адресную строку браузера и оседает в истории, в логах прокси, в Referer. Поэтому в редиректе едет одноразовый код, бесполезный сам по себе, а настоящие токены приходят по защищённому каналу, куда браузер не заглядывает. Устаревший implicit flow отдавал токен прямо во фрагменте URL; сейчас его считают небезопасным, и в OAuth 2.1 он запрещён.

Два параметра, о которых спрашивают

  • state - случайное значение: его кладут в сессию и сверяют на возврате. Так флоу защищён от CSRF (иначе атакующий подсунет свой код и привяжет твой аккаунт к своему).
  • В PKCE клиент генерирует code_verifier, шлёт его SHA-256 как code_challenge, а при обмене предъявляет оригинал. Без verifier перехваченный код бесполезен. PKCE обязателен для SPA и мобильных (секрета там нет), а по OAuth 2.1 - вообще для всех.

Чем OIDC отличается

access_token в OAuth говорит «предъявитель имеет право на такие-то действия». Он адресован API, и клиенту разбирать его вообще не положено. id_token в OIDC всегда JWT, он адресован клиенту и говорит «этот пользователь только что аутентифицирован там-то»: sub, iss, aud, nonce, auth_time, профиль. Ещё OIDC стандартизует /.well-known/openid-configuration, JWKS-эндпоинт с публичными ключами и /userinfo. Часто ошибаются, принимая access_token за доказательство личности, а он может оказаться непрозрачной строкой, в которой нет вообще ничего.

Суть: оба - примитивные схемы «предъяви долгоживущий секрет». Уместны для машин, а не для людей: server-to-server интеграции, вебхуки, служебные и внутренние эндпоинты. Для пользовательских сессий не годятся ни тот, ни другой.

Basic auth

Это Authorization: Basic base64(user:pass), то есть пароль в открытом виде в каждом запросе: base64 ни от кого не защищает. Без TLS применять нельзя категорически. Логаута нет, истечения нет, браузер кэширует учётку и подставляет её сам. Уместен он там, где надо закрыть /metrics и /debug/pprof или стенд от посторонних, и в простой интеграции со скриптом на cron, где TLS есть и ротация не нужна. На сервере сравнивать обязательно через subtle.ConstantTimeCompare.

API-ключи

// ключ отдаём один раз при создании, в БД только хеш и префикс
key := "sk_live_" + base64url(random(24))          // 192 бита энтропии
row := APIKey{
    Prefix:    key[:14],                            // для поиска и показа в UI
    Hash:      sha256.Sum256([]byte(key)),          // быстрый хеш ок: секрет высокоэнтропийный
    Scopes:    []string{"orders:read"},
    ExpiresAt: time.Now().AddDate(1, 0, 0),
}
// проверка: находим по префиксу, сравниваем хеши constant-time

Почему для ключа хватает SHA-256, а для пароля нет? Пароль придумывает человек, энтропии в нём мало, и его подбирают по словарю. Случайный 192-битный ключ перебрать нельзя, а медленная KDF только жжёт CPU на каждом запросе.

Правила, которые ждут в ответе

  • Ключ передают в заголовке (Authorization: Bearer или X-API-Key), никогда в query: URL пишется в access-логи, прокси и Referer.
  • У каждого ключа свои скоупы и права, на каждую интеграцию отдельный ключ, чтобы отзыв одного не ломал остальные.
  • Срок жизни и ротация: на время переключения можно держать два активных ключа.
  • Rate limit по ключу, лог использования, дата последнего применения.
  • Для вебхуков наружу лучше не ключ, а HMAC-подпись тела с меткой времени (X-Signature, X-Timestamp): секрет вовсе не передаётся, а метка защищает от повторной отправки.
  • Самый серьёзный вариант для межсервисного трафика - mTLS: клиентский сертификат вместо строки, отзыв через CRL, срок жизни короткий.
Суть: RBAC - права привязаны к роли («менеджер может отменять заказы»), ABAC - решение считается функцией от атрибутов субъекта, объекта и контекста («менеджер может отменять заказы своего филиала в рабочее время»). Middleware закрывает уровень маршрута, но проверка владения объектом живёт в сервисе.

RBAC

Пользователь → роли → права (permissions), права проверяются в коде. Проверять надо право, а не роль. if user.Role == "admin" закладывает бомбу замедленного действия: появится второй тип админа, и придётся править сотню мест. if user.Can("order:cancel") переживёт и новые роли, и их перекройку. RBAC понятен бизнесу и легко ложится на интерфейс администратора.

ABAC

Решение принимает политика по кортежу атрибутов: кто (роль, отдел, стаж), что (владелец объекта, статус, сумма), в каком контексте (время, IP, был ли MFA). Так выражается то, что RBAC не умеет: «сумму больше миллиона согласует только руководитель», «к персональным данным пускать только из офисной сети». Реализуют это кодом в сервисном слое или отдельным движком политик (OPA/Rego, Casbin, Cedar). Платишь за это более сложной отладкой («почему отказ?») и аудитом решений.

Middleware

func RequirePerm(p string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            u, ok := auth.From(r.Context())     // положил middleware аутентификации
            if !ok { fail(w, 401); return }
            if !u.Can(p) { fail(w, 403); return }
            next.ServeHTTP(w, r)
        })
    }
}
r.With(RequirePerm("order:cancel")).Post("/orders/{id}/cancel", h.Cancel)

// а это middleware сделать не может, он не знает, чей это заказ:
func (s *Service) Cancel(ctx context.Context, orderID, userID int64) error {
    o, err := s.repo.Get(ctx, orderID)
    if err != nil { return err }
    if u, _ := auth.From(ctx); o.UserID != userID && !u.Can("order:cancel:any") {
        return ErrForbidden           // отдать наружу как 404, если факт существования секретен
    }
    ...
}

Главные грабли

  • Авторизация только в middleware даёт IDOR: право «отменять заказы» есть, а что заказ чужой, никто не проверил.
  • Fail-open: нет токена → в контексте пустой пользователь → политика «нет запрета» → пускаем. По умолчанию должно быть «запрещено»: безопаснее закрыть роутер целиком и явно пометить публичные маршруты, чем наоборот.
  • Права в JWT удобны (ноль запросов), но протухают до конца TTL; критичные права лучше читать из источника истины.
  • Полезный тест: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика. Иначе однажды новый эндпоинт приедет голым.
Суть: пароль хешируется намеренно медленной функцией с уникальной солью. MD5/SHA спроектированы быстрыми - видеокарта считает миллиарды хешей в секунду, и весь словарь перебирается за часы. Дефолт на сегодня - argon2id, надёжная классика - bcrypt с cost 12.

Почему быстрые хеши не годятся

Стойкость к перебору упирается не в математику, а в стоимость одной попытки. У SHA-256 она измеряется наносекундами, так что утёкшая база с SHA-хешами фактически и есть база паролей: 8-символьные пароли перебираются полностью, остальное ловят словарями с правилами мутаций. Медленная KDF поднимает цену попытки до ~250 мс, и перебор перестаёт окупаться. А memory-hard функции (scrypt, argon2) вдобавок требуют десятки мегабайт на попытку, и у GPU с ASIC пропадает преимущество: памяти на ядро у них мало.

Соль и перец

  • Соль - уникальная случайная строка на каждый пароль, лежит рядом с хешем открыто. Прятать пароль она не должна. Она ломает rainbow-таблицы, даёт одинаковым паролям разные хеши (иначе по базе видно, у кого пароль 123456) и не даёт атаковать всю базу разом.
  • Перец - общий секрет вне БД (Vault, HSM, конфиг). Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши бесполезны. Минус: перец трудно ротировать.
  • В bcrypt соль зашита в саму строку хеша, генерировать её руками не надо, а в argon2 соль и параметры хранишь сам.

Что ещё обязательно назвать

  • Constant-time сравнение результата (subtle.ConstantTimeCompare, у bcrypt - CompareHashAndPassword).
  • User enumeration: для несуществующего логина всё равно прогонять фиктивный хеш и отвечать одинаковым «неверный логин или пароль», иначе аккаунты вычисляются по времени ответа.
  • Ротация параметров: cost хранится в самом хеше; если при успешном логине параметры оказались устаревшими, хеш пересчитывают, ведь только в этот момент открытый пароль есть на руках.
  • Rate limit и блокировка после серии неудач: без них медленная KDF не спасёт от онлайн-перебора вроде password spraying или credential stuffing.
  • Ограничение bcrypt: он учитывает только первые 72 байта пароля. В Go GenerateFromPassword на более длинном вернёт ErrPasswordTooLong, поэтому длинную парольную фразу заранее сворачивают в SHA-256 или берут argon2id.
  • Не требуй «спецсимвол и смену раз в 90 дней»: актуальный NIST советует длину, проверку по списку скомпрометированных паролей и никакой принудительной ротации.
Суть: к секрету три требования - не попасть в git, не светиться в логах и меняться без релиза. Переменные окружения закрывают первое, Vault или облачный KMS закрывают все три и добавляют аудит, TTL и динамические учётки.

Лестница зрелости

  1. Хардкод в коде - катастрофа: секрет в git навсегда, у всех форков и в CI-кэшах.
  2. Переменные окружения по 12-factor дают минимальный приемлемый уровень. Минусы: видны в /proc/PID/environ, в docker inspect, утекают в краш-репорты; ротация = рестарт.
  3. Файл, смонтированный в контейнер, можно обновить без рестарта, на него действуют права доступа; приложение должно уметь перечитывать его по сигналу.
  4. Kubernetes Secret: RBAC, монтирование как файл или env. Обязательно проговори: это base64, а не шифрование, и без включённого encryption-at-rest в etcd секрет лежит практически открытым.
  5. Vault / KMS дают централизованное хранение, аудит доступа, TTL, автоматическую ротацию, динамические секреты. Взамен получаешь ещё одну зависимость в рантайме и задачу аутентифицировать само приложение.

AppRole в двух предложениях

У сервиса есть role_id (не секрет, лежит в конфиге) и secret_id (короткоживущий, его доставляют отдельно, оркестратором или через response wrapping). Пару меняют на токен с TTL, приложение периодически его продлевает и по нему читает секреты. Артефактов два, чтобы утечка одного не давала доступ. Отдельный козырь Vault - динамические секреты: он сам заводит временного пользователя в PostgreSQL с TTL в час, и красть пароль от базы становится почти бессмысленно.

Ротация как рутина

Ротация должна быть скучной операцией, а не подвигом. Для этого приложение держит два действующих значения одновременно: два ключа подписи с разными kid (выкатили новый на проверку → переключили подпись → сняли старый через окно), старый и новый пароль БД, два API-ключа у интеграции. Тогда секрет меняется без одномоментного рестарта всего.

Что делать, если секрет уже в git

Удалить файл новым коммитом недостаточно: он остался в истории, в форках и в клонированных CI-кэшах. Порядок строго такой: сначала отозвать и перевыпустить секрет, потом (по желанию) чистить историю git filter-repo/BFG. Ротация обязательна, чистка истории - косметика. В пайплайн ставят gitleaks/trufflehog, а секретным типам в коде дают String(), который возвращает "[REDACTED]", чтобы секрет не уехал в лог вместе со структурой конфига.

Что стоит запомнить

  • Подписанный JWT защищает от подмены claims, но не скрывает содержимое и не отзывается автоматически до истечения срока.
  • Аутентификация успешна, даже если пользователь не владеет запрошенным ресурсом; authorization проверяет действие и объект.
  • Короткий access token ограничивает окно риска, а refresh token требует более защищённого хранения и ротации.

Частые ошибки

  • Не доверяй id ресурса из URL без проверки владельца/разрешения в запросе.
  • Не храни секреты или чувствительные claims в payload JWT в расчёте на шифрование подписи.
  • Не помещай bearer token в URL: он попадёт в историю, логи и referer.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай срок JWT

Проверка подписи успешна, а claims такие: sub=user-42, exp=18:00 UTC, now=18:03 UTC. Должен ли middleware принять токен и какую гарантию даёт подпись?

sub=user-42
exp=2026-10-03T18:00:00Z
now=2026-10-03T18:03:00Z
signature=valid
Ответ и разбор

Токен должен быть отклонён как истёкший, несмотря на корректную подпись. Подпись подтверждает, что claims не были изменены доверенным ключом, но не отменяет exp и сама не подтверждает доступ пользователя к конкретному ресурсу.

Код-ревью

2. Добавь проверку владения

Middleware проверил access token, но handler получает заказ только по id. Исправь запрос так, чтобы нельзя было получить чужой заказ заменой ID в URL.

id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
order, err := repo.GetByID(r.Context(), id)
Ответ и разбор

Извлеки проверенный principal из context и используй запрос с обоими условиями: GetForUser(ctx, id, principal.UserID), SQL вида WHERE id=$1 AND user_id=$2. Это делает проверку владения частью выборки и не оставляет окно между отдельной проверкой и чтением. Не раскрывай, существует ли чужой заказ, если политика API этого не допускает.

Применение

3. Выбери хранение токена в браузере

SPA вызывает API из браузера. Сравни хранение bearer access token в localStorage с HttpOnly Secure cookie и назови связанные угрозы, не объявляя один способ универсально безопасным.

Ответ и разбор

localStorage доступен JavaScript и уязвим к краже при XSS. HttpOnly Secure cookie недоступна скрипту, но браузер автоматически отправляет cookie, поэтому нужны SameSite и CSRF-защита для изменяющих запросов. Выбор зависит от модели клиента, XSS/CSRF защиты, доменов, CORS и ротации токенов.

Применение

4. Выбери отзыв токена

Access JWT живёт час, но сотрудника нужно отключить немедленно. Назови два способа отзыва и их цену.

Ответ и разбор

Можно уменьшить TTL access token, чтобы сократить окно отзыва, и использовать короткоживущий access token с серверной проверкой refresh/session state. Для немедленной блокировки можно проверять denylist или версию сессии при запросе, но это добавляет shared state/сетевую зависимость и кэширование. Подпись JWT сама по себе не сообщает, что пользователя уже отключили.