Shanraq.org Shanraq.org
Регистрация: что хранить вместо пароля
IT

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, и по имени видно, что внутри.

Карта урока

Карта урока: пароль, соль и стоимость

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему сайт, умеющий прислать ваш старый пароль, хранит его неправильно?
  2. Зачем bcrypt нужен медленный, если все остальные функции стараются быть быстрыми?
  3. Почему на несуществующую почту программа всё равно сравнивает пароль с хешем?

Задание

Обязательное. Заведите в блоге регистрацию. Миграция добавляет таблицу users. Страница GET /register показывает форму, POST /register заводит человека: почта в нижнем регистре, пароль от восьми знаков и не длиннее 72 байт, в базу — только хеш. Занятая почта отвечает понятной фразой, а не пятисоткой.

Всё это сделано в step-21 — сверьтесь после того, как напишете сами.

По желанию.

  • Измерьте, сколько времени занимает GenerateFromPassword при стоимости 10, 12 и 14 на вашей машине. Выберите ту, при которой проверка укладывается в 0,1–0,5 секунды, — и запомните, что на сервере число будет другим.
  • Напишите тест: зарегистрировали, вошли с верным паролем, вошли с неверным. Третий случай должен вернуть ErrBadLogin, а не что-то другое.
  • Попробуйте зарегистрироваться с паролем из 37 казахских букв и убедитесь, что человек видит понятное сообщение.

Куда это встанет в блоге

У блога появляется первый человек. Пока он может только зарегистрироваться — вход, который что-то запоминает, будет в следующем уроке про сессии.

Долги. Почту никто не подтверждает, и завести чужую пока можно. Число попыток входа не ограничено. И списка утёкших паролей мы не проверяем — всё это по-хорошему нужно, но каждое тянет на свой урок.

Ответы

Показать ответы
  1. Потому что прислать пароль можно, только если он хранится в читаемом виде — открытым текстом или зашифрованным ключом, который лежит рядом. Хеш прислать нельзя: из него пароль не достать.
  2. Потому что скорость здесь работает на того, кто подбирает. Быстрая функция позволяет проверить миллионы вариантов в секунду; медленная превращает перебор словаря в месяцы. Нам одна проверка в сотую долю секунды ничего не стоит, а перебирающему стоит всего.
  3. Чтобы время ответа было одинаковым. Иначе быстрый ответ означал бы «такой почты нет», и по секундомеру можно было бы собрать список зарегистрированных людей.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.