Содержание урока · 9 разделов
Сценарий, от которого защищается весь урок: база утекла. Дамп попал в чужие руки вместе с колонкой users.password — и теперь единственное, что стоит между паролями пользователей и их взломом, — то, как вы эту колонку заполняли. Открытый текст: взломано мгновенно, все переустановки паролей и письма с извинениями не спасут репутацию. MD5 или SHA256: пароли не открытым текстом, но оба алгоритма проектировались быть быстрыми, и в этом их приговор: на видеокарте перебираются миллиарды вариантов в секунду, а словарь частых паролей прогоняется по дампу за минуты. Хеширование паролей golang решает это иначе: для паролей нужны медленные хеши с настройкой медленности и встроенной солью — bcrypt из официального расширения golang.org/x/crypto. Он ничего не шифрует: хеш нельзя развернуть обратно, а проверка работает пересчётом. В этом уроке — регистрация с GenerateFromPassword, вход с CompareHashAndPassword, разбор формата хеша и подбор стоимости. Здесь же и граница темы: JWT из прошлых уроков выдаётся только после того, как пароль сошёлся, — bcrypt это самое начало цепочки логина.
Регистрация: хешируем пароль перед сохранением
GOfunc register(w http.ResponseWriter, r *http.Request) {
var in struct {
Login string
Password string
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "битое тело запроса", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword(
[]byte(in.Password), bcrypt.DefaultCost,
)
if err != nil {
http.Error(w, "не удалось хешировать пароль", http.StatusInternalServerError)
return
}
err = saveUser(r.Context(), in.Login, string(hash))
if err != nil {
http.Error(w, "логин занят", http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)
}GenerateFromPassword принимает пароль байтами и стоимость; внутри она сама генерирует случайную соль и возвращает готовый хеш строкой из шестидесяти символов — отдельную колонку под соль заводить не нужно. В базу идёт string(hash) и больше ничего: пароль в открытом виде не существует нигде, кроме полученного запроса.
bcrypt go: соль, cost и формат хеша#
Хеш bcrypt — строка вроде $2a$10$N9qo8uLOickgx2ZMRZoMye... — и она читается слева направо как анкета. Первые четыре символа — версия алгоритма, дальше два знака стоимости: десятка из примера выше — это cost. Затем двадцать два символа соли в собственной кодировке bcrypt и тридцать один символ собственно хеша: вместе ровно шестьдесят. Соль здесь — самое важное инженерное решение: она случайна для каждого вызова GenerateFromPassword, поэтому один и тот же пароль двух пользователей даёт два разных хеша, и радужные таблицы, заранее посчитанные для миллиона популярных паролей, становятся бесполезны — нападающему пришлось бы считать их заново для каждой соли. Стоимость cost управляет медленностью: это степень двойки в раундах подготовки ключа, каждый плюс один к cost удваивает время хеширования. В пакете заданы границы: bcrypt.MinCost, bcrypt.DefaultCost и bcrypt.MaxCost; по умолчанию берётся десятая стоимость — десятки миллисекунд на типичном сервере, точное число меряйте у себя. Границы пакета защищают и от наивной экономии: cost ниже минимума или выше максимума вернёт ошибку недопустимой стоимости.
Cost четыре ради скорости в тестах, случайно уехавший в прод — история, которую я видел не раз: логин летает, а перебор паролей по утёкшему дампу становится быстрым и дешёвым, ровно чего bcrypt должен был не дать. Моё правило: DefaultCost в коде, а настройка — переменной окружения, потому что цена миллисекунд на логине не окупается скоростью перебора у нападающего. Честная граница исключения: тяжёлые фоновые пакетные проверки с тысячами вызовов могут взять cost поменьше — но тогда это явно вынесенная константа, а не дефолт по забывчивости.
Вход: CompareHashAndPassword и пересчёт#
Проверка пароля не разворачивает хеш — она пересчитывает. CompareHashAndPassword читает из сохранённого хеша соль и стоимость, прогоняет присланный пароль через те же шаги и сравнивает результаты: совпало — ошибка nil, не совпало — bcrypt.ErrMismatchedHashAndPassword. Из механики пересчёта следуют три практических правила. Первое: никогда не сравнивайте хеши сами — ни строками, ни повторным хешированием с ручной сверкой: пакет сравнивает в постоянном времени, чтобы утечка по времени не подсказывала наполовину угаданные символы; собственное сравнение этот слой защиты выбрасывает. Второе: соль отдельно не хранят и не передают — она уже внутри хеша, и второй колонки в схеме таблицы не заводить. Третье: ответ на неверный логин и неверный пароль — одинаковый, «неверный логин или пароль» со статусом 401: уточнение «такого пользователя нет» превращает эндпоинт в перечислитель зарегистрированных логинов. Ошибку пакета наружу не пробрасываем — она для лога, а не для клиента; честные ответы API уже разбирались в уроке про ошибки токенов, и принцип здесь тот же.
Логин: сверка пароля с хешем
GOfunc login(w http.ResponseWriter, r *http.Request) {
var in struct {
Login string
Password string
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "битое тело запроса", http.StatusBadRequest)
return
}
hash, err := loadHash(r.Context(), in.Login)
if err != nil {
http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword(hash, []byte(in.Password))
if err != nil {
http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
return
}
// пароль сошёлся: здесь выдаётся пара access/refresh из прошлого урока
access, refresh, err := issuePair(loadUserID(r.Context(), in.Login), "user")
if err != nil {
http.Error(w, "не удалось выпустить пару", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"access": access, "refresh": refresh,
})
}Обе ветки отказа отвечают одинаково: нет пользователя и не сошёлся пароль клиент не различает — это лишает перечислитель логинов обратной связи. Ошибка пакета уходит в лог, а не в ответ. На успехе выдаётся пара access/refresh из прошлого урока: bcrypt закрывает первый шаг логина, JWT — второй.
Цена хеширования и подбор cost под себя#
Медленность bcrypt — не побочный эффект, а платёж за безопасность, и её стоит измерить руками: тест с GenerateFromPassword на своём железе за минуту покажет, что DefaultCost стоит у вас пятьдесят или сто миллисекунд. Отсюда два практических следствия. Первое — бюджет входа: десятки миллисекунд на логин незаметны человеку, но заметны на пике нагрузки, поэтому генерацию хеша не ставят в горячий путь каждого запроса — она живёт в регистрации и смене пароля, а логин проверяет, что тоже стоит ресурсов, и ограничивается rate limiter-ом. Второе — рост стоимости со временем: железо дешевеет, и cost, безопасный пять лет назад, сегодня слабее; рабочая схема — при успешном входе сравнивать cost сохранённого хеша с текущим стандартом команды и, если он отстал, тихо пересчитывать хеш на новом cost. У bcrypt есть и жёсткий потолок: пароль длиннее семидесяти двух байт алгоритм не принимает, современные версии x/crypto возвращают на этом ошибку ErrPasswordTooLong, так что проверку длины лучше сделать до вызова, а не ловить её на регистрации. Честная оговорка про соседей по x/crypto: argon2id там же и считается современнее — он стойче к GPU-атакам по памяти, но bcrypt остаётся рабочей классикой с двадцатилетней историей и живёт во всех легаси-схемах; менять его на argon2 стоит осознанно, а не по привычке к новизне.
Вариация: переезд со SHA-256 без сброса паролей#
Наследство встречается чаще зелёного поля: в таблице лежат старые SHA-256, а сбрасывать пароли всех пользователей разом нельзя. Рабочий мост — двойное хеширование со флагом схемы в отдельной колонке. Новые записи живут как bcrypt от sha-256-хеша пароля; при входе код сначала считает SHA-256 от присланного пароля, потом сверяет его CompareHashAndPassword с сохранённым bcrypt-хешем. Старые записи проверяются прямым сравнением SHA-256, и каждый успешный вход по ним немедленно пересчитывается на bcrypt со снятием флага. Через пару месяцев активности флаги исчезают, старый столбец удаляется, и схема становится обычной. Граница метода: это временный переход, а не архитектура — держать два механизма вечно значит удваивать поверхность проверки, поэтому срок жизни моста фиксируется в задаче, а не в воспоминаниях.
Чем пароли хешировать: сравнение по сути#
| Алгоритм | Соль | Медленность | Годность для паролей |
|---|---|---|---|
| MD5 | нет в схеме паролей | микросекунды, ломается GPU | нет: снят даже для контрольных сумм |
| SHA-256 | нужно городить самому | быстрый — это его назначение | нет: скорость против вас |
| bcrypt | встроена в хеш, случайна | настраивается cost, удвоение на единицу | да: рабочая классика |
| argon2id | встроена | настраивается памятью и временем | да: современный выбор x/crypto |
Правила паролей, которые легко сломать#
- Пароль не логируется никогда — ни на регистрации, ни на входе, ни в ошибках; в логах логин допустим, пароль нет.
- Одинаковый ответ на незнакомый логин и неверный пароль: 401 и «неверный логин или пароль».
- Соль не хранится отдельно: она внутри хеша bcrypt, вторая колонка не нужна.
- Сравнение паролей — только CompareHashAndPassword: ручное сравнение строк выбрасывает защиту по времени.
- Длина пароля проверяется до хеширования: потолок bcrypt — 72 байта, современные версии пакета вернут ErrPasswordTooLong.
- Cost хеша растёт со временем: при входе старый слабый cost тихо пересчитывается на текущий.
Проверка: стоимость по умолчанию
Какой cost bcrypt использует по умолчанию в пакете x/crypto?
Проверка: два пользователя, один пароль
Два пользователя зарегистрировались с одинаковым паролем. Что лежит в базе?
Проверка: успешное сравнение
Что возвращает bcrypt.CompareHashAndPassword на совпавшем пароле?
Чужой пароль против хеша
Прочитайте код и выберите, что он выведет:
hash, _ := bcrypt.GenerateFromPassword(
[]byte("правильный-пароль"), bcrypt.DefaultCost)
err := bcrypt.CompareHashAndPassword(hash, []byte("другой-пароль"))
// что в err и что увидит клиент в login-хендлере урока?Ссылка на предыдущий урок здесь прямая: пара access/refresh из урока про refresh-токены выдаётся в login-хендлере сразу после успешного CompareHashAndPassword — аутентификация по паролю и аутентификация по токену склеиваются именно на этой строке. Дальше логин обрастает обвязкой: в уроке про middleware аутентификации пара станет обязательной на защищённых маршрутах, а итоговый проект курса соберёт всё вместе — регистрацию с bcrypt, выдачу пары и роли. Для хранения хешей в настоящей базе пригодится курс по GORM — там модель User со строковым полем под шестидесятисимвольный хеш ложится на таблицу в одну строку. Карта всех тем — на странице курса jwt-go. Честная оговорка про запуск: bcrypt запускается и без сервера — локальный Go, go get golang.org/x/crypto/bcrypt и короткий тест генерации со сравнением покажут вам цену DefaultCost на вашем железе быстрее любого текста.
Мини-тест на две строки: GenerateFromPassword пароля с DefaultCost, вокруг — time.Now и время выполнения. Прогоните трижды и возьмите среднее: это цена одного логина на вашем сервере. Затем запустите с cost на единицу больше и убедитесь, что время удвоилось — так на глаз подбирается значение для переменной окружения вместо дефолта.
Допишите регистрацию и вход
Восстановите вызовы bcrypt: генерация хеша с дефолтной стоимостью и сравнение на входе.
Соберите проверку пароля на входе
Расставьте строки login-хендлера: от загрузки хеша до тихого отказа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: bcrypt
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- bcrypt из golang.org/x/crypto — медленный хеш для паролей: соль внутри строки, cost настраивает медленность, хеш занимает 60 символов.
- Регистрация — GenerateFromPassword с DefaultCost; в базу идёт string(hash) и ничего больше.
- Вход — CompareHashAndPassword: пересчёт по соли из хеша, совпадение даёт nil, расхождение — ErrMismatchedHashAndPassword.
- Ручное сравнение хешей недопустимо: пакет сравнивает в постоянном времени против утечки по таймингу.
- Одинаковый ответ на неверный логин и неверный пароль — без перечисления зарегистрированных логинов.
- Потолок bcrypt — 72 байта пароля, ErrPasswordTooLong в современных версиях; cost растёт со временем пересчётом при входе.
Домашнее задание
Соберите регистрацию и логин с bcrypt локально: GenerateFromPassword на регистрации, CompareHashAndPassword на входе, пары access/refresh из прошлого урока на успехе. Измерьте время DefaultCost на своём железе, затем проверьте, что неверный пароль и незнакомый логин отвечают одинаково. Добавьте пересчёт хеша при входе, если cost хеша ниже текущего стандарта команды.
Личные заметкиПусто
Вопросы и ответы по уроку
bcrypt или argon2id — что выбрать для нового проекта?
Оба из x/crypto и оба рабочие: argon2id современнее и стойче к GPU-атакам за счёт требования памяти, bcrypt — классика с двадцатилетней историей и простыми настройками. Если команда уже живёт с bcrypt — менять без причины не нужно; для нового проекта argon2id оправдан, а главное в любом случае — не MD5 и не SHA-256.
Почему нельзя просто SHA-256 с солью?
SHA-256 быстрый по замыслу, и скорость играет против вас: миллион вариантов на видеокарте считаются мгновенно, соль лишь убивает радужные таблицы, но не сам перебор. Парольные хеши специально медленные и настраиваемые — bcrypt и argon2 придуманы именно для этого.
Что делать с паролем длиннее 72 байт?
bcrypt не принимает такие пароли, современные версии x/crypto возвращают ErrPasswordTooLong. Лучшее решение — проверять длину до хеширования и отдавать внятную ошибку. Обрезать пароль молча не стоит: пользователь думает, что защищён длинной фразой, а работает только её кусок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния