Go: с нуля до своего блога Урок 36 из 50
Регистрация: что хранить вместо пароля
Тридцать шестой урок курса по Go. Пароль в базе не хранят — хранят bcrypt-хеш, у которого соль внутри, а стоимость снаружи. Измерено: sha256 даёт девять миллионов хешей в секунду, bcrypt десять. Плюс предел в 72 байта, о который казахский пароль спотыкается уже на тридцать седьмой букве.
Зачем это нужно
Исходить надо из того, что база однажды утечёт. Не обязательно из-за взлома: забытая копия на ноутбуке, чужой доступ к серверу, ошибка в настройке. Вопрос не в том, случится ли это, а в том, что именно окажется у чужого человека.
Если в колонке лежит qwerty123, у него сразу и ваш блог, и почта половины читателей — люди повторяют пароли. Если там $2a$10$..., у него список бессмысленных строк, и подобрать по ним пароли стоит месяцев работы и денег за электричество.
Разница между этими двумя случаями — одна библиотека и один урок.
Сразу целиком
Новая папка, go mod init sabaq33. Кроме драйвера базы понадобится ещё одна библиотека:
go get golang.org/x/crypto/bcrypt
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
sqlite "modernc.org/sqlite"
)
const uniqueViolation = 2067
// Ошибки, которые видит остальная программа. Про bcrypt и коды базы она
// не знает ничего.
var (
ErrEmailTaken = errors.New("почта уже занята")
ErrBadLogin = errors.New("почта или пароль не подходят")
ErrShort = errors.New("пароль короче восьми знаков")
ErrLong = errors.New("пароль длиннее 72 байт")
)
// dummyHash — настоящий хеш от строки, которую никто не знает. Нужен, чтобы
// на несуществующую почту программа тратила столько же времени, сколько на
// существующую: иначе по времени ответа можно перебрать чужие адреса.
const dummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
func main() {
os.Remove("blog.db")
db, err := sql.Open("sqlite", "blog.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`create table users (
id integer primary key,
email text not null unique,
password_hash text not null,
created_at text not null default (datetime('now'))
) strict`); err != nil {
log.Fatal(err)
}
fmt.Println("== регистрация")
fmt.Println("первый раз: ", register(db, "Aigul@Example.KZ", "пароль123456"))
fmt.Println("та же почта: ", register(db, "aigul@example.kz", "другойпароль"))
fmt.Println("короткий: ", register(db, "bota@example.kz", "1234"))
fmt.Println("длинный: ", register(db, "bota@example.kz", strings.Repeat("қ", 37)))
fmt.Println("== что лежит в базе")
var hash string
db.QueryRow(`select password_hash from users where email = ?`, "aigul@example.kz").Scan(&hash)
fmt.Println(hash)
fmt.Println("== вход")
fmt.Println("верный пароль:", timed(func() error { return login(db, "aigul@example.kz", "пароль123456") }))
fmt.Println("чужой пароль: ", timed(func() error { return login(db, "aigul@example.kz", "угадаю") }))
fmt.Println("нет почты: ", timed(func() error { return login(db, "joq@example.kz", "угадаю") }))
}
// register заводит человека. Пароль в базу не попадает — попадает его хеш.
func register(db *sql.DB, email, password string) error {
email = strings.ToLower(strings.TrimSpace(email))
// Проверяем длину до хеширования: считать знаки для человека, а байты —
// для bcrypt, у которого предел ровно 72 байта.
if utf8.RuneCountInString(password) < 8 {
return ErrShort
}
if len(password) > 72 {
return ErrLong
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = db.Exec(`insert into users (email, password_hash) values (?, ?)`,
email, string(hash))
var serr *sqlite.Error
if errors.As(err, &serr) && serr.Code() == uniqueViolation {
return ErrEmailTaken
}
return err
}
// login отвечает одинаково и на чужой пароль, и на несуществующую почту.
func login(db *sql.DB, email, password string) error {
email = strings.ToLower(strings.TrimSpace(email))
var hash string
err := db.QueryRow(`select password_hash from users where email = ?`, email).Scan(&hash)
switch {
case errors.Is(err, sql.ErrNoRows):
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
return ErrBadLogin
case err != nil:
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return ErrBadLogin
}
return nil
}
// timed печатает ответ вместе с тем, сколько он занял.
func timed(f func() error) string {
start := time.Now()
err := f()
return fmt.Sprintf("%v (%s)", err, time.Since(start).Round(10*time.Millisecond))
}
Вывод:
== регистрация
первый раз: <nil>
та же почта: почта уже занята
короткий: пароль короче восьми знаков
длинный: пароль длиннее 72 байт
== что лежит в базе
$2a$10$CPTNvmNIELTL/CIzcKn.O..8V8H6CdotJMEyq9YAaqMYGG0TccjIe
== вход
верный пароль: <nil> (100ms)
чужой пароль: почта или пароль не подходят (100ms)
нет почты: почта или пароль не подходят (100ms)
Хеш у вас будет другой — и это не сбой, а главное свойство bcrypt, о котором ниже.
Разбор
Хеш — не шифр
Шифр можно расшифровать: у того, кто знает ключ, есть обратный путь. У хеша обратного пути нет — это функция, которая перемалывает вход в строку заданной длины и не умеет ничего восстанавливать.
Отсюда способ проверки. Пароль при входе не «расшифровывают», а прогоняют через ту же функцию и сравнивают результат с тем, что лежит в базе. Совпало — пароль верный. Сам пароль системе больше знать не нужно никогда.
Образ. Мясорубка. Фарш получить легко, вернуть из фарша кусок мяса — нельзя. Но если вы прокрутите второй такой же кусок и фарш выйдет тем же, значит, мясо было то же.
Отсюда и правило, по которому узнают плохой сайт: если он умеет прислать вам ваш старый пароль на почту — он хранит его так, что его прочтёт любой, кто дотянется до базы.
Почему sha256 не годится
Первое, что приходит в голову, — взять из стандартной библиотеки sha256. Он и правда хеш. И он не подходит по двум причинам, обе измеримые.
sha256 дважды:
40cd254a34f5a1ada2c930b10a6803178d59c95878f1a5a0a47a265cf3773f58
40cd254a34f5a1ada2c930b10a6803178d59c95878f1a5a0a47a265cf3773f58
bcrypt дважды:
$2a$10$B6f9E.32rVSimIvGCrDVXOe0US6dFyEnZuAuKdJu5e2DJ6sMMlena
$2a$10$Gj/1qQo.8lG7j1920Ljvj.pooV9A1pfYcnTNZwEJ80NmGUOinTSna
за секунду: sha256 — 9289350 хешей
за секунду: bcrypt — 10 хешей
Причина первая: один и тот же пароль даёт один и тот же хеш. Значит, в утёкшей базе сразу видно, у кого пароли совпали, а готовые таблицы «хеш → слово» для миллиардов популярных паролей давно посчитаны за нас.
Причина вторая: скорость. Девять с лишним миллионов хешей в секунду на обычном ноутбуке — а на видеокарте счёт идёт на миллиарды. Словарь из миллиона популярных паролей перебирается быстрее, чем вы читаете этот абзац.
Для паролей быстрый хеш — недостаток. И это единственное место в программировании, где мы платим за медленность деньги.
bcrypt: соль внутри, стоимость снаружи
Сразу оговорка о выборе. Сегодня OWASP советует для новых систем Argon2id, а bcrypt называет запасным вариантом — там, где Argon2 или scrypt недоступны. Мы берём bcrypt по двум причинам: он есть в golang.org/x/crypto без дополнительных зависимостей и у него нет четырёх параметров настройки, на которых новичок ошибается. Для учебного блога этого достаточно; для системы с настоящими деньгами читайте Argon2id.
bcrypt решает обе беды сразу.
Одинаковый пароль даёт разные хеши: в вывод выше загляните ещё раз. Так работает соль — случайные байты, которые подмешиваются к паролю и хранятся тут же, внутри строки. Готовая таблица бесполезна: под каждую соль её надо считать заново.
Строка устроена так:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ └── соль и сам хеш
│ └───── стоимость: 10
└───────── версия алгоритма
Стоимость — это степень двойки: 10 значит 2¹⁰ проходов. Каждая единица удваивает время. У меня вышло десять хешей в секунду против девяти миллионов у sha256 — почти в миллион раз медленнее, и ровно за это мы и платим.
Проверять хеш умеет bcrypt.CompareHashAndPassword: он сам достаёт из строки версию, стоимость и соль, поэтому ничего дополнительно хранить не надо.
Стоимость подбирают под конкретный сервер, а не по числу из статьи: столько, чтобы проверка пароля занимала примерно от 0,1 до 0,5 секунды. Меньше — легче перебирать, больше — читатель ждёт на каждом входе. Через три года железо станет быстрее, стоимость поднимут на единицу — и старые хеши продолжат работать, потому что стоимость записана в каждом из них.
72 байта: предел, о который спотыкается казахский
У bcrypt жёсткое ограничение на длину пароля, и оно измеряется в байтах:
72 букв, 72 байт -> <nil>
73 букв, 73 байт -> bcrypt: password length exceeds 72 bytes
36 букв, 72 байт -> <nil>
37 букв, 74 байт -> bcrypt: password length exceeds 72 bytes
Латиницей это 72 знака, о которые никто не спотыкается. А казахская или русская буква занимает два байта — те самые два байта из урока про руны, — и предел наступает на 37-й букве.
Раньше библиотеки молча обрезали пароль на 72 байтах, и человек с длинным паролем годами входил с его началом. Теперь Go возвращает ошибку, и это лучше: обрезать пароль тихо — значит врать про его надёжность.
В блоге это значит одно: проверяйте len(password) > 72 до хеширования и говорите человеку понятную фразу, а не «internal server error».
Одинаковый ответ на чужой пароль и чужую почту
Посмотрите на три последние строки вывода. Все три ответа одинаковы по тексту и по времени.
Текст одинаков нарочно. Если на несуществующую почту сказать «нет такого пользователя», а на существующую с неверным паролем — «пароль не тот», то любой желающий за вечер соберёт список ваших читателей: подставляй адреса и смотри на ответ. Это называется перебором пользователей, и защита от него — одна общая фраза.
Со временем сложнее. Когда почты нет, сравнивать не с чем, и функция вернулась бы мгновенно — а на существующей потратила бы свои сто миллисекунд. Разница заметна по секундомеру, и список читателей всё равно соберётся. Поэтому в ветке «нет такой почты» мы сравниваем пароль с заведомо чужим хешем: результат не нужен, нужно потраченное время.
Что проверять в пароле, а что не надо
Правила «минимум одна заглавная, одна цифра и один значок» устарели и с 2017 года их не рекомендует даже NIST — организация, которая их когда-то и ввела. Люди на такие правила отвечают одинаково: Password1!. Сложность выдумана, а память страдает.
Что действительно помогает:
- длина. NIST в редакции 2024 года требует от пароля, который стоит один, не меньше пятнадцати знаков; восемь допустимы только рядом со вторым фактором. Верхнюю границу ставьте не ниже 64 знаков и считайте символы, а не байты: длинная фраза из трёх слов надёжнее короткой мешанины;
- проверка по списку утёкших. Есть открытые базы известных паролей, и не пустить в неё пароль полезнее любых значков;
- не заставлять менять пароль без причины. Принудительная смена каждые три месяца приводит к
Qazaq2026, потомQazaq2027.
И одно, о чём забывают: пароль нельзя писать в журнал. Ни целиком, ни «первые три знака для отладки».
Что кладут в таблицу users
Колонок ровно столько, сколько нужно:
create table users (
id integer primary key,
email text not null unique,
password_hash text not null,
created_at text not null default (datetime('now'))
) strict
email приводится к нижнему регистру перед вставкой: Aigul@Example.KZ и aigul@example.kz — один человек, и unique должен это понимать. В выводе видно, что вторая регистрация на ту же почту отбита, хотя написана она была иначе.
Колонки password в этой таблице нет и не будет. Есть только password_hash, и по имени видно, что внутри.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему сайт, умеющий прислать ваш старый пароль, хранит его неправильно?
- Зачем bcrypt нужен медленный, если все остальные функции стараются быть быстрыми?
- Почему на несуществующую почту программа всё равно сравнивает пароль с хешем?
Задание
Обязательное. Заведите в блоге регистрацию. Миграция добавляет таблицу users. Страница GET /register показывает форму, POST /register заводит человека: почта в нижнем регистре, пароль от восьми знаков и не длиннее 72 байт, в базу — только хеш. Занятая почта отвечает понятной фразой, а не пятисоткой.
Всё это сделано в step-21 — сверьтесь после того, как напишете сами.
По желанию.
- Измерьте, сколько времени занимает
GenerateFromPasswordпри стоимости 10, 12 и 14 на вашей машине. Выберите ту, при которой проверка укладывается в 0,1–0,5 секунды, — и запомните, что на сервере число будет другим. - Напишите тест: зарегистрировали, вошли с верным паролем, вошли с неверным. Третий случай должен вернуть
ErrBadLogin, а не что-то другое. - Попробуйте зарегистрироваться с паролем из 37 казахских букв и убедитесь, что человек видит понятное сообщение.
Куда это встанет в блоге
У блога появляется первый человек. Пока он может только зарегистрироваться — вход, который что-то запоминает, будет в следующем уроке про сессии.
Долги. Почту никто не подтверждает, и завести чужую пока можно. Число попыток входа не ограничено. И списка утёкших паролей мы не проверяем — всё это по-хорошему нужно, но каждое тянет на свой урок.
Ответы
Показать ответы
- Потому что прислать пароль можно, только если он хранится в читаемом виде — открытым текстом или зашифрованным ключом, который лежит рядом. Хеш прислать нельзя: из него пароль не достать.
- Потому что скорость здесь работает на того, кто подбирает. Быстрая функция позволяет проверить миллионы вариантов в секунду; медленная превращает перебор словаря в месяцы. Нам одна проверка в сотую долю секунды ничего не стоит, а перебирающему стоит всего.
- Чтобы время ответа было одинаковым. Иначе быстрый ответ означал бы «такой почты нет», и по секундомеру можно было бы собрать список зарегистрированных людей.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.