Аутентификация, JWT и хранение паролей
Почти любой backend рано или поздно отвечает на два вопроса: кто делает запрос и имеет ли он право это делать. Первый вопрос называется аутентификацией. Второй - авторизацией.
Эти слова похожи, но путать их опасно:
- аутентификация: "кто ты?";
- авторизация: "что тебе можно?".
Пользователь может успешно войти в систему, но не иметь права удалить чужой ресурс. Значит, аутентификация прошла, а авторизация должна вернуть запрет.
Сценарий входа
Типичный сценарий входа:
- Пользователь отправляет email и пароль.
- Сервер находит пользователя по email.
- Сервер сравнивает пароль с хешем.
- Если все верно, сервер выдает access token.
- Клиент отправляет token в следующих запросах.
Важная деталь: сервер не должен хранить пароль в открытом виде. Он хранит только хеш.
Хранение пароля
Пароль хранится через специальную password hashing функцию, например bcrypt:
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) }
В базу сохраняется hash, а не исходный пароль.
Проверка:
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) if err != nil { return ErrInvalidCredentials }
Почему нельзя использовать обычный SHA-256? Потому что пароли часто слабые, а атакующий может быстро перебирать миллионы вариантов. bcrypt специально медленнее и использует salt, чтобы усложнить массовый перебор.
Сообщения при ошибке входа
Не возвращайте разные ошибки:
email not found wrong password
Так атакующий может проверять, какие email зарегистрированы. Лучше единое сообщение:
invalid credentials
В логах можно сохранить техническую причину аккуратно, без пароля.
JWT
JWT состоит из трех частей:
header.payload.signature
- header описывает алгоритм;
- payload содержит claims;
- signature доказывает, что token подписан сервером.
Payload можно прочитать без секрета. Поэтому JWT - это подписанный, а не зашифрованный токен.
Хорошие claims:
{ "sub": "user_id", "exp": 1760000000, "iat": 1759990000, "role": "student" }
Плохие claims:
{ "password": "...", "api_key": "...", "credit_card": "..." }
exp, iat и sub
Частые claims:
sub- subject, обычно id пользователя;exp- когда token истекает;iat- когда token был выпущен;iss- кто выпустил token;aud- для кого token предназначен.
Минимально полезный JWT почти всегда должен иметь sub и exp. Token без срока действия сложно отозвать и опасно хранить.
Access и refresh tokens
Access token живет недолго: например, 15-60 минут. Его отправляют в API-запросах:
Authorization: Bearer <access_token>
Refresh token живет дольше и используется для получения нового access token.
Почему не сделать один вечный token? Потому что token может утечь. Короткий access token уменьшает окно риска. Refresh token можно хранить более осторожно, ротировать и отзывать.
В реальных системах refresh token часто хранят серверно или делают отзывным, чтобы пользователь мог выйти из аккаунта на конкретном устройстве.
Middleware авторизации
Middleware читает заголовок:
Authorization: Bearer <token>
Затем:
- проверяет формат;
- проверяет подпись;
- проверяет срок действия;
- достает
user_id; - кладет данные пользователя в context запроса.
func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r.Header.Get("Authorization")) if token == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } userID, err := verifyToken(token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), userIDKey{}, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) }
В handler после middleware уже можно получить текущего пользователя из context.
401 и 403
401 Unauthorized означает: пользователь не аутентифицирован. Например, token отсутствует или неверный.
403 Forbidden означает: пользователь известен, но права недостаточны.
if review.UserID != currentUser.ID && currentUser.Role != "admin" { http.Error(w, "forbidden", http.StatusForbidden) return }
Frontend и API-клиенты по-разному реагируют на эти статусы. При 401 можно отправить пользователя на login. При 403 нужно показать, что доступа нет.
Роли и права
Простой вариант авторизации - роли:
student;mentor;admin.
Но роль не всегда достаточно точна. Иногда нужна проверка владения ресурсом:
if book.OwnerID != currentUser.ID { return ErrForbidden }
Хороший backend не доверяет frontend. Даже если кнопка удаления скрыта в UI, сервер все равно должен проверить право на удаление.
Где хранить token
В web-приложениях часто выбирают между:
- memory;
- localStorage;
- httpOnly cookie.
У каждого варианта есть trade-off. localStorage удобен, но уязвимее при XSS. httpOnly cookie недоступна JavaScript, но требует аккуратной настройки SameSite, Secure и CSRF-защиты для некоторых сценариев.
Для учебного backend важно запомнить базу: token - это credential. Относитесь к нему как к паролю.
Чеклист
- Пароли хранятся через bcrypt или современную password hashing функцию.
- Исходный пароль не логируется и не сохраняется.
- Ошибки login не раскрывают, существует ли email.
- JWT не содержит секретов.
- У token есть
exp. - Access token живет недолго.
- Refresh token можно отозвать или ротировать.
- Middleware отделена от обработчиков.
401и403используются по смыслу.- Авторизация проверяется на сервере, не только во frontend.