Go: с нуля до своего блога Урок 37 из 50
Сессии и cookie: как сервер помнит, кто вошёл
Тридцать седьмой урок курса по Go. HTTP не помнит ничего: каждый запрос приходит от незнакомца. Печенье с номером пользователя подделывается за секунду. Поэтому в браузер уходит случайный токен, а в базу его хеш, и всё закрывается тремя словами: HttpOnly, Secure, SameSite.
Зачем это нужно
В прошлом уроке человек зарегистрировался. Теперь он хочет войти — и тут выясняется неприятное: HTTP не помнит ничего.
Каждый запрос приходит сам по себе. Сервер ответил на один — и забыл о нём; следующий запрос от того же человека для него неотличим от запроса случайного прохожего. Никакой связи между ними в самом протоколе нет.
Связь приходится делать самим, и делают её печеньем — маленьким кусочком, который сервер просит браузер сохранить и присылать обратно с каждым запросом.
Сразу целиком
Новая папка, go mod init sabaq34. Программа поднимает сервер и сама же ходит на него — так виден и заголовок, и то, что происходит с каждым запросом:
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
const cookieName = "session"
// sessionLife — сколько живёт вход. Неделя: достаточно, чтобы не входить
// каждый день, и мало, чтобы забытая сессия не жила годами.
const sessionLife = 7 * 24 * time.Hour
func main() {
os.Remove("blog.db")
db := open()
defer db.Close()
srv := httptest.NewServer(routes(db))
defer srv.Close()
fmt.Println("== вход")
fmt.Println("чужой пароль: ", login(srv.URL, "aigul@example.kz", "угадаю"))
header, token := loginOK(srv.URL, "aigul@example.kz", "пароль123456")
fmt.Println("верный пароль: ", header)
fmt.Println("== кто пришёл")
fmt.Println("без печенья: ", ask(srv.URL, ""))
fmt.Println("с печеньем: ", ask(srv.URL, token))
fmt.Println("подделка: ", ask(srv.URL, "user_id=1"))
fmt.Println("чужой токен: ", ask(srv.URL, strings.Repeat("A", 43)))
fmt.Println("== выход")
fmt.Println("выходим: ", logout(srv.URL, token))
fmt.Println("та же печенька:", ask(srv.URL, token))
}
func routes(db *sql.DB) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
email, ok := current(db, r)
if !ok {
fmt.Fprint(w, "гость")
return
}
fmt.Fprintf(w, "вошли как %s", email)
})
mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
id, err := authenticate(db, r.FormValue("email"), r.FormValue("password"))
if err != nil {
http.Error(w, "почта или пароль не подходят", http.StatusUnauthorized)
return
}
token, err := newSession(db, id)
if err != nil {
http.Error(w, "не вышло", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
Expires: time.Now().Add(sessionLife),
HttpOnly: true,
Secure: overHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusSeeOther)
})
mux.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
db.Exec(`delete from sessions where token_hash = ?`, hashToken(c.Value))
}
// Браузеру говорят забыть печенье: то же имя, пустое значение и срок
// в прошлом. Строку из базы мы уже удалили — это важнее.
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: "", Path: "/",
Expires: time.Unix(0, 0), MaxAge: -1,
HttpOnly: true, Secure: overHTTPS(r), SameSite: http.SameSiteLaxMode,
})
fmt.Fprint(w, "вышли")
})
return mux
}
// newSession выдаёт случайный токен и кладёт в базу его хеш. В печенье уходит
// сам токен, в базу — только хеш: утёкшая база не даёт войти ни за кого.
func newSession(db *sql.DB, userID int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
_, err := db.Exec(
`insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`,
hashToken(token), userID, time.Now().Add(sessionLife).UTC().Format(time.RFC3339))
return token, err
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// current отвечает, кто прислал этот запрос, и не верит печенью на слово:
// в печенье лежит только случайная строка, а всё остальное — в базе.
func current(db *sql.DB, r *http.Request) (string, bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return "", false
}
var email string
err = db.QueryRow(`select u.email from sessions s
join users u on u.id = s.user_id
where s.token_hash = ? and s.expires_at > ?`,
hashToken(c.Value), time.Now().UTC().Format(time.RFC3339)).Scan(&email)
if err != nil {
return "", false
}
return email, true
}
func authenticate(db *sql.DB, email, password string) (int64, error) {
var id int64
var hash string
err := db.QueryRow(`select id, password_hash from users where email = ?`,
strings.ToLower(strings.TrimSpace(email))).Scan(&id, &hash)
if err != nil {
return 0, errors.New("не подходит")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return 0, errors.New("не подходит")
}
return id, nil
}
// overHTTPS — по-настоящему ли запрос пришёл по TLS. От этого зависит флаг
// Secure: печенье с ним не ходит по обычному http, поэтому на своей машине
// оно было бы поставлено один раз и никогда не прислано обратно.
func overHTTPS(r *http.Request) bool { return r.TLS != nil }
Помощники (open, login, ask и остальные) лежат во втором файле, helpers.go, и делают то, что обычно делает браузер: step-22 показывает то же самое в блоге, где браузер настоящий.
Вывод:
== вход
чужой пароль: 401, печенья нет
верный пароль: 303, session=0HhwSnNtWOgQrUxJZv9YXCFz9-Ojj0XdOO6zNClfRe4; Path=/; Expires=Sun, 13 Sep 2026 13:08:58 GMT; HttpOnly; Secure; SameSite=Lax
== кто пришёл
без печенья: гость
с печеньем: вошли как aigul@example.kz
подделка: гость
чужой токен: гость
== выход
выходим: вышли
та же печенька: гость
Разбор
Печенье — это заголовок, а не магия
http.SetCookie пишет в ответ заголовок Set-Cookie, и он весь виден во второй строке вывода. Браузер, получив его, сохраняет строку и с этого момента добавляет заголовок Cookie к каждому запросу на этот сайт.
Всё. Никакого хранилища на сервере печенье не требует и ничего само не значит: это просто строка, которую браузер носит туда-обратно.
Образ. Номерок в гардеробе. В нём нет ни пальто, ни имени владельца — только число. Пальто лежит у гардеробщика, и по номерку он находит именно ваше.
Почему в печенье нельзя класть user_id
Соблазн понятен: положить user_id=1 и читать его на сервере. Строка «подделка» в выводе показывает, чем это кончается — вернее, чем это кончилось бы, если бы сервер ей верил.
Печенье живёт в браузере. Его владелец может открыть панель разработчика и написать там что угодно: user_id=1, admin=true, balance=1000000. Ничего не подписано, ничего не проверяется, и это две секунды работы.
Правило: в печенье не кладут ничего, чему сервер собирается верить. Туда кладут номерок — случайную строку, которая сама по себе не значит ничего.
Случайность бывает разная
Токен делает crypto/rand, а не math/rand, и это не придирка.
math/rand создан для другого: он быстрый и предсказуемый по устройству. Зная несколько выданных им чисел, следующее вычисляют. Для перемешивания списка это неважно, для номерка от чужого входа — важно очень.
crypto/rand берёт случайность у операционной системы и создан ровно для таких случаев. Тридцать два байта из него — это столько вариантов, что перебор бессмысленен при любом железе.
base64.RawURLEncoding превращает байты в строку, которую можно положить в заголовок: 32 байта дают 43 знака, и в них нет ни +, ни /, ни =, портящих ссылки.
В базу кладут хеш, а не сам токен
Посмотрите на таблицу sessions: там token_hash, а не token.
Причина та же, что и с паролями. Если база утечёт, а в ней лежат живые токены, укравший входит за любого, не подбирая ничего. Хеш этого не даёт: чтобы войти, нужен сам токен, а из хеша он не достаётся.
Разница с паролем одна: здесь достаточно sha256, а не bcrypt. Пароль человек придумывает сам, и он бывает qwerty123 — от перебора спасает только медленность. Токен придумали мы, в нём 32 случайных байта, и перебирать его бессмысленно, поэтому платить за медленность нечем.
Три слова в заголовке
В Set-Cookie из вывода стоят три слова, и каждое закрывает свою дыру:
- HttpOnly — печенье не видно из JavaScript. Если на страницу попадёт чужой скрипт, он не сможет прочитать токен и отправить его себе. Это главная защита от кражи сессии через XSS;
- Secure — браузер отправит печенье только по HTTPS. Без этого токен уходит открытым текстом по любому кафейному вайфаю. Ставить его наглухо нельзя: пока блог работает по
http://127.0.0.1, помеченное так печенье браузер примет, но обратно не пришлёт — вы войдёте, и следующая же страница вас забудет. Поэтому флаг решается по запросу: пришёл он по TLS — ставим, пришёл по обычному http на вашей машине — нет; - SameSite=Lax — печенье не прикладывается к межсайтовым
POST, к запросам картинок, стилей и к фоновым обращениям. А вот при обычном переходе по ссылке с чужого сайта — этоGETверхнего уровня — оно приложится, и это сделано нарочно, чтобы вы не выходили из блога каждый раз, когда открываете его из поиска. Значит,Laxзакрывает подделку формы, но не заменяет проверку жетоном: отдельный урок про CSRF ещё будет.
Плюс Path=/ — на весь сайт, и Expires — до какого времени браузер хранит. Без Expires печенье живёт до закрытия браузера.
Выход — это удалить строку в базе
Обратите внимание на последние две строки вывода: после выхода та же самая печенька больше не работает.
Так и должно быть. Просить браузер забыть печенье — вежливость, а не защита: копия строки могла остаться где угодно. Настоящий выход — это delete from sessions, после которого токен не находится, кто бы его ни принёс.
Отсюда же и польза от таблицы сессий: можно показать человеку список его входов и дать кнопку «выйти везде», а это просто удаление всех его строк.
Чего в этом уроке ещё нет
Каждый вход выдаёт новый токен — это видно в коде, — поэтому подсунутый заранее чужой номерок после входа авторизованным не станет. Закрепление сессии здесь закрыто.
А вот старые сессии остаются: вошли с чужого ноутбука, сменили пароль — та строка в таблице продолжает работать. Для кнопки «выйти со всех устройств» их нужно удалять, и это ещё не сделано.
Просроченные строки никто не убирает: запрос их не найдёт, но в базе они копятся. В настоящем блоге раз в сутки запускают delete from sessions where expires_at < ....
И самого вошедшего мы каждый раз ищем заново в каждом обработчике. Правильное место для этого — обёртка, которая делает это один раз и кладёт найденное в контекст запроса; про контекст будет отдельный урок.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему в печенье нельзя положить
user_idи верить ему? - Почему токен в базе хешируют, но не через
bcrypt, как пароль? - Почему выход — это удаление строки, а не просьба к браузеру забыть печенье?
Задание
Обязательное. Сделайте в блоге вход и выход. Миграция заводит таблицу sessions. POST /login проверяет пару из прошлого урока и выдаёт печенье, POST /logout удаляет строку. В шапке появляется имя вошедшего и ссылка «выйти», а гостю — «войти».
Всё это сделано в step-22 — сверьтесь после того, как напишете сами.
По желанию.
- Выдавайте новый токен при каждом входе, а старый удаляйте. Проверьте, что старая печенька перестаёт работать.
- Покажите на странице профиля список сессий с временем и кнопкой «выйти везде».
- Уберите
HttpOnlyи посмотрите в панели разработчика, что теперь видно изdocument.cookie.
Куда это встанет в блоге
Блог впервые различает людей. Дальше на этом строится всё: права, чужие черновики, «мои статьи».
Долги. Сессии не обновляются и не чистятся. Вошедшего ищет каждый обработчик сам. И форма входа пока не защищена от перебора паролей — ни задержки, ни ограничения попыток.
Ответы
Показать ответы
- Потому что печенье лежит у человека в браузере, и он может написать в нём что угодно за две секунды. Верить можно только тому, что сервер держит у себя; в печенье уходит бессмысленный номерок.
- Потому что
bcryptнужен там, где секрет придумал человек и он может быть слабым: медленность спасает от перебора. Токен придумали мы, в нём 32 случайных байта, перебирать его бессмысленно — достаточно быстрогоsha256. - Потому что копия печенья могла остаться где угодно, и просьба к браузеру ни к чему не обязывает. Пока строка в базе жива, токен работает; удалили строку — он мёртв у всех сразу.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.