Shanraq.org Shanraq.org
Сессия мен cookie: сервер кімнің кіргенін қалай есте сақтайды
IT

Go тілі: нөлден өз блогыңызға дейін 37-сабақ (барлығы 50)

Сессия мен cookie: сервер кімнің кіргенін қалай есте сақтайды

Go курсының отыз жетінші сабағы. HTTP ештеңе есте сақтамайды: әр сұраныс бейтаныс адамнан келеді. Пайдаланушы нөмірі жазылған cookie бір секундта қолдан жасалады. Сондықтан браузерге кездейсоқ токен, дерекқорға оның хеші кетеді, ал бәрін үш сөз жабады: HttpOnly, Secure, SameSite.

Не үшін керек

Өткен сабақта адам тіркелді. Енді ол кіргісі келеді — сол жерде жағымсыз нәрсе белгілі болады: HTTP ештеңе есте сақтамайды.

Әр сұраныс өз алдына келеді. Сервер біреуіне жауап берді де, оны ұмытты; сол адамның келесі сұранысы ол үшін кездейсоқ өтіп бара жатқан адамның сұранысынан ажыратылмайды. Протоколдың өзінде олардың арасында ешқандай байланыс жоқ.

Байланысты өзіміз жасауға тура келеді, әрі оны cookie-мен жасайды — сервер браузерден сақтап қоюын және әр сұраныспен қайта жіберуін сұрайтын шағын түйіршікпен.

Бірден тұтас

Жаңа қалта, 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("cookie-сіз:     ", ask(srv.URL, ""))
	fmt.Println("cookie-мен:     ", 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("сол cookie:     ", 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))
		}
		// Браузерге cookie-ді ұмыт дейді: сол ат, бос мән және өткен мерзім.
		// Дерекқордағы жолды әлдеқашан өшірдік — маңыздысы сол.
		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 кездейсоқ токен беріп, дерекқорға оның хешін салады. Cookie-ге
// токеннің өзі, дерекқорға тек хеш кетеді: ағып кеткен дерекқор ешкімнің
// атынан кіруге мүмкіндік бермейді.
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 бұл сұранысты кім жібергенін айтады әрі cookie-ге сеніп қалмайды:
// cookie-де тек кездейсоқ жол жатыр, қалғанының бәрі дерекқорда.
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 жалаушасы соған
// байланысты: онымен белгіленген cookie кәдімгі http-пен жүрмейді, сондықтан
// өз машинаңызда ол бір рет қойылып, ешқашан кері келмес еді.
func overHTTPS(r *http.Request) bool { return r.TLS != nil }

Көмекшілер (open, login, ask және қалғаны) екінші файлда, helpers.go ішінде жатыр әрі әдетте браузер істейтін нәрсені істейді: step-22 дәл осыны блогта, браузері нағыз жерде көрсетеді.

Шығатыны:

== кіру
бөтен құпиясөз:  401, cookie жоқ
дұрыс құпиясөз:  303, session=0HhwSnNtWOgQrUxJZv9YXCFz9-Ojj0XdOO6zNClfRe4; Path=/; Expires=Sun, 13 Sep 2026 13:08:58 GMT; HttpOnly; Secure; SameSite=Lax
== кім келді
cookie-сіз:      қонақ
cookie-мен:      aigul@example.kz болып кірдіңіз
қолдан жасалған: қонақ
бөтен токен:     қонақ
== шығу
шығамыз:         шықтыңыз
сол cookie:      қонақ

Талдау

Cookie — сиқыр емес, тақырып

http.SetCookie жауапқа Set-Cookie тақырыбын жазады, әрі ол шығудың екінші жолында түгел көрініп тұр. Оны алған браузер жолды сақтайды да, осы сәттен бастап осы сайтқа кететін әр сұранысқа Cookie тақырыбын қосады.

Болғаны сол. Cookie серверде ешқандай қойма талап етпейді әрі өзі ештеңе білдірмейді: бұл — браузер ары-бері тасып жүретін жол ғана.

Елестетіп көріңіз. Киім ілгіштегі нөмірше. Онда пальто да, иесінің аты да жоқ — тек сан. Пальто ілгіш жақта жатыр, әрі ол нөмірше бойынша дәл сіздікін табады.

Cookie-ге user_id салуға неге болмайды

Азғырық түсінікті: user_id=1 салып, оны серверде оқу. Шығудағы «қолдан жасалған» жолы мұның немен бітетінін көрсетеді — дәлірек айтқанда, сервер оған сенсе, немен бітер еді.

Cookie браузерде тұрады. Оның иесі әзірлеуші панелін ашып, онда не болса соны жаза алады: user_id=1, admin=true, balance=1000000. Ештеңеге қол қойылмаған, ештеңе тексерілмейді, әрі бұл екі секундтық жұмыс.

Ереже: cookie-ге сервер сенбек болған ештеңені салмайды. Оған нөмірше салады — өзі ештеңе білдірмейтін кездейсоқ жол.

Кездейсоқтық әртүрлі болады

Токенді math/rand емес, crypto/rand жасайды, әрі бұл жайдан-жай талап емес.

math/rand басқа нәрсе үшін жасалған: ол жылдам әрі құрылысы бойынша болжамды. Одан шыққан бірнеше санды біле отырып, келесісін есептеп шығарады. Тізімді араластыру үшін бұл маңызды емес, бөтен кірудің нөміршесі үшін өте маңызды.

crypto/rand кездейсоқтықты операциялық жүйеден алады әрі дәл осындай жағдайлар үшін жасалған. Одан алынған отыз екі байт — кез келген темірмен теруді мағынасыз ететін нұсқалар саны.

base64.RawURLEncoding байттарды тақырыпқа салуға болатын жолға айналдырады: 32 байт 43 таңба береді, әрі оларда сілтемені бұзатын +, /, = жоқ.

Дерекқорға токеннің өзі емес, хеші салынады

sessions кестесіне қараңыз: онда token емес, token_hash тұр.

Себебі құпиясөздегімен бірдей. Дерекқор ағып кетсе әрі онда тірі токендер жатса, ұрлаған адам ештеңе термей-ақ кез келгеннің атынан кіреді. Хеш мұны бермейді: кіру үшін токеннің өзі керек, ал ол хештен шықпайды.

Құпиясөзден бір айырмасы бар: мұнда bcrypt емес, sha256 жеткілікті. Құпиясөзді адам өзі ойлап табады, әрі ол qwerty123 болуы мүмкін — теруден тек баяулық құтқарады. Токенді біз ойлап таптық, онда 32 кездейсоқ байт бар, оны теру мағынасыз, сондықтан баяулық үшін төлейтін ештеңе жоқ.

Тақырыптағы үш сөз

Шығудағы Set-Cookie ішінде үш сөз тұр, әрі әрқайсысы өз тесігін жабады:

  • HttpOnly — cookie JavaScript-тен көрінбейді. Бетке бөтен скрипт түссе, ол токенді оқып, өзіне жібере алмайды. Бұл — XSS арқылы сессияны ұрлаудан басты қорғаныс;
  • Secure — браузер cookie-ді тек HTTPS арқылы жібереді. Онсыз токен кез келген дәмхана вайфаиімен ашық мәтінмен кетеді. Оны мықтап қойып тастауға болмайды: блог http://127.0.0.1-де тұрғанда браузер осылай белгіленген cookie-ді қабылдайды, бірақ кері жібермейді — сіз кіресіз де, келесі бет сізді ұмытады. Сондықтан жалауша сұраныс бойынша шешіледі: TLS-пен келсе — қоямыз, машинаңыздағы кәдімгі http болса — жоқ;
  • SameSite=Lax — cookie сайтаралық POST-қа, сурет пен стиль сұраныстарына әрі фондық жүгінулерге тіркелмейді. Ал бөтен сайттағы кәдімгі сілтемемен өткенде — бұл жоғарғы деңгейдегі GET — тіркеледі, әрі бұл әдейі: іздеуден блогты ашқан сайын шығып қалмаңыз деп. Демек, Lax форманы қолдан жасауды жабады, бірақ жетонмен тексеруді алмастырмайды: CSRF туралы бөлек сабақ әлі болады.

Оның үстіне Path=/ — бүкіл сайтқа, әрі Expires — браузер қай уақытқа дейін сақтайды. Expires болмаса, cookie браузер жабылғанша тұрады.

Шығу — бұл дерекқордағы жолды өшіру

Шығудың соңғы екі жолына назар аударыңыз: шыққаннан кейін дәл сол cookie енді жұмыс істемейді.

Солай болуға тиіс те. Браузерден cookie-ді ұмытуын сұрау — қорғаныс емес, әдептілік: жолдың көшірмесі қай жерде де қалуы мүмкін. Нағыз шығу — бұл delete from sessions, содан кейін токенді кім әкелсе де табылмайды.

Сессия кестесінің пайдасы да содан: адамға оның кірулерінің тізімін көрсетіп, «барлық жерден шығу» батырмасын беруге болады, ал ол — оның барлық жолын өшіру ғана.

Бұл сабақта әлі не жоқ

Әр кіру жаңа токен береді — бұл кодта көрініп тұр, — сондықтан алдын ала тығылған бөтен нөмірше кіргеннен кейін авторизацияланбайды. Сессияны бекіту мұнда жабылған.

Ал ескі сессиялар қалады: бөтен ноутбуктен кіріп, құпиясөзді ауыстырсаңыз да, кестедегі сол жол жұмыс істей береді. «Барлық құрылғыдан шығу» түймесі үшін оларды жою керек, әрі бұл әлі жасалған жоқ.

Мерзімі өткен жолдарды ешкім тазаламайды: сұраныс оларды таппайды, бірақ дерекқорда олар жиналады. Нағыз блогта тәулігіне бір рет delete from sessions where expires_at < ... іске қосады.

Ал кірген адамның өзін біз әр өңдеушіде қайтадан іздейміз. Бұған дұрыс орын — мұны бір рет істеп, тапқанын сұраныс контекстіне салатын орама; контекст туралы бөлек сабақ болады.

Сабақ картасы

Сабақ картасы: браузерде нөмірше, қалғанының бәрі дерекқорда

Өз сөзіңізбен айтыңыз

Қарамай, дауыстап немесе қағазға жауап беріңіз. Жауаптары — сабақтың соңында.

  1. Cookie-ге user_id салып, оған сенуге неге болмайды?
  2. Дерекқордағы токенді неге хештейді, бірақ құпиясөз сияқты bcrypt арқылы емес?
  3. Шығу неге браузерден cookie-ді ұмытуын сұрау емес, жолды өшіру?

Тапсырма

Міндетті. Блогта кіру мен шығуды жасаңыз. Көші-қон sessions кестесін ашады. POST /login өткен сабақтағы жұпты тексеріп, cookie береді, POST /logout жолды өшіреді. Жоғарғы жолақта кірген адамның аты мен «шығу» сілтемесі, ал қонаққа — «кіру» пайда болады.

Мұның бәрі step-22 ішінде жасалған — өзіңіз жазғаннан кейін салыстырыңыз.

Қалауыңызша.

  • Әр кіруде жаңа токен беріп, ескісін өшіріңіз. Ескі cookie-дің жұмысын қоятынын тексеріңіз.
  • Профиль бетінде сессиялар тізімін уақытымен және «барлық жерден шығу» батырмасымен көрсетіңіз.
  • HttpOnly-ды алып тастап, әзірлеуші панелінен document.cookie ішінде не көрінетініне қараңыз.

Блогта бұл қайда тұрады

Блог алғаш рет адамдарды ажырата бастады. Бұдан былай бәрі осыған құрылады: құқықтар, бөтен нобайлар, «менің мақалаларым».

Қарыздар. Сессиялар жаңартылмайды әрі тазаланбайды. Кірген адамды әр өңдеуші өзі іздейді. Ал кіру формасы құпиясөзді теруден әзірге қорғалмаған — кідіріс те, әрекет саны да шектелмеген.

Жауаптар

Жауаптарды көрсету
  1. Себебі cookie адамның браузерінде жатыр, әрі ол онда екі секундта не болса соны жаза алады. Сенуге болатыны — сервер өзінде ұстайтын нәрсе ғана; cookie-ге мағынасыз нөмірше кетеді.
  2. Себебі bcrypt құпияны адам ойлап тапқан әрі ол әлсіз болуы мүмкін жерде керек: баяулық теруден құтқарады. Токенді біз ойлап таптық, онда 32 кездейсоқ байт, оны теру мағынасыз — жылдам sha256 жеткілікті.
  3. Себебі cookie-дің көшірмесі қай жерде де қалуы мүмкін, әрі браузерден сұрау ешнәрсеге міндеттемейді. Дерекқордағы жол тірі тұрғанда токен жұмыс істейді; жолды өшірдіңіз — ол бәрінде бірден өледі.

Дереккөздер

Мәтінде қате не теру қатесі кездессе, бізге айтыңыз

Тапсырманы тексеру

Алдымен VS Code-та шешіп, іске қосыңыз — редактор қатені сол жерде көрсетеді. Дайын шешімді осында қойыңыз. Тексеретін — модель: ол қатені атап көрсетеді, бірақ дайын жауапты бермейді.

Тексеру үшін кіру керек. Кіру

Пікірлер (0)

Әзірге пікір жоқ. Бірінші болыңыз.