Shanraq.org Shanraq.org
Сессии и cookie: как сервер помнит, кто вошёл
IT

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 < ....

И самого вошедшего мы каждый раз ищем заново в каждом обработчике. Правильное место для этого — обёртка, которая делает это один раз и кладёт найденное в контекст запроса; про контекст будет отдельный урок.

Карта урока

Карта урока: номерок в браузере, всё остальное в базе

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

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

  1. Почему в печенье нельзя положить user_id и верить ему?
  2. Почему токен в базе хешируют, но не через bcrypt, как пароль?
  3. Почему выход — это удаление строки, а не просьба к браузеру забыть печенье?

Задание

Обязательное. Сделайте в блоге вход и выход. Миграция заводит таблицу sessions. POST /login проверяет пару из прошлого урока и выдаёт печенье, POST /logout удаляет строку. В шапке появляется имя вошедшего и ссылка «выйти», а гостю — «войти».

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

По желанию.

  • Выдавайте новый токен при каждом входе, а старый удаляйте. Проверьте, что старая печенька перестаёт работать.
  • Покажите на странице профиля список сессий с временем и кнопкой «выйти везде».
  • Уберите HttpOnly и посмотрите в панели разработчика, что теперь видно из document.cookie.

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

Блог впервые различает людей. Дальше на этом строится всё: права, чужие черновики, «мои статьи».

Долги. Сессии не обновляются и не чистятся. Вошедшего ищет каждый обработчик сам. И форма входа пока не защищена от перебора паролей — ни задержки, ни ограничения попыток.

Ответы

Показать ответы
  1. Потому что печенье лежит у человека в браузере, и он может написать в нём что угодно за две секунды. Верить можно только тому, что сервер держит у себя; в печенье уходит бессмысленный номерок.
  2. Потому что bcrypt нужен там, где секрет придумал человек и он может быть слабым: медленность спасает от перебора. Токен придумали мы, в нём 32 случайных байта, перебирать его бессмысленно — достаточно быстрого sha256.
  3. Потому что копия печенья могла остаться где угодно, и просьба к браузеру ни к чему не обязывает. Пока строка в базе жива, токен работает; удалили строку — он мёртв у всех сразу.

Источники

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

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

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

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

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

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