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", "құпиясөз123"))
	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", "құпиясөз123") }))
	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))

	// Ұзындықты хештеу алдында тексереміз: таңбаны адам үшін, ал байтты
	// шегі дәл 72 байт болатын bcrypt үшін санаймыз.
	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$nTE4/9z10pwaEHxq9H.eauen3dE2Mjpa1u7HROr93moTncwrfi8OK
== кіру
дұрыс құпиясөз: <nil> (100ms)
бөтен құпиясөз: пошта немесе құпиясөз келмейді (100ms)
пошта жоқ:      пошта немесе құпиясөз келмейді (100ms)

Сіздегі хеш басқа болады — бұл ақау емес, төменде айтылатын bcrypt-тің басты қасиеті.

Талдау

Хеш — шифр емес

Шифрді шешуге болады: кілтті білетін адамда кері жол бар. Хеште кері жол жоқ — бұл кірісті белгіленген ұзындықтағы жолға айналдыратын, ал ештеңені қалпына келтіре алмайтын функция.

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

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

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

sha256 неге жарамайды

Бірінші ойға келетіні — стандартты кітапханадан sha256 алу. Ол шынымен де хеш. Әрі ол екі себеппен жарамайды, екеуі де өлшенеді.

sha256 екі рет:
  4e08c77114b0fa49134da9a0ee78fff6e031619a7c8c17472eadcca74cc9eaf4
  4e08c77114b0fa49134da9a0ee78fff6e031619a7c8c17472eadcca74cc9eaf4
bcrypt екі рет:
  $2a$10$nuTziFp2qUTn6pRJr48jbuYbKRJHGnnHuYSua7IhcSp2aV1wmd6De
  $2a$10$.9oEyFz0lpUYj0Of46dvqOt.R/NUjTjRrrXYCGk/9Wrn5ZcHsja8W
секундына: sha256 — 9447892 хеш
секундына: 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 ішінде жасалған — өзіңіз жазғаннан кейін салыстырыңыз.

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

  • Құны 10, 12 және 14 болғанда GenerateFromPassword өз машинаңызда қанша уақыт алатынын өлшеңіз. Тексеру 0,1–0,5 секундқа сыятынын таңдаңыз — әрі серверде бұл сан басқа болатынын есте ұстаңыз.
  • Тест жазыңыз: тіркедік, дұрыс құпиясөзбен кірдік, дұрыс емесімен кірдік. Үшінші жағдай басқа емес, дәл ErrBadLogin қайтаруға тиіс.
  • 37 қазақ әрпінен тұратын құпиясөзбен тіркеліп көріңіз де, адамға түсінікті хабар шыққанына көз жеткізіңіз.

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

Блогта бірінші адам пайда болады. Ол әзірге тек тіркеле алады — бірдеңені есте сақтайтын кіру сессиялар туралы келесі сабақта болады.

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

Жауаптар

Жауаптарды көрсету
  1. Себебі құпиясөзді жіберу оны оқылатын түрде сақтағанда ғана мүмкін — ашық мәтінмен немесе қасында жатқан кілтпен шифрланған күйде. Хешті жіберуге болмайды: одан құпиясөз шықпайды.
  2. Себебі мұнда жылдамдық теріп жүрген адамға жұмыс істейді. Жылдам функция секундына миллиондаған нұсқаны тексеруге мүмкіндік береді; баяуы сөздікті теруді айларға айналдырады. Бізге секундтың жүзден біріндегі бір тексеру ештеңеге тұрмайды, ал теріп жүргенге бәріне тұрады.
  3. Жауап уақыты бірдей болуы үшін. Әйтпесе жылдам жауап «мұндай пошта жоқ» дегенді білдірер еді, әрі секундөлшеуішпен тіркелген адамдардың тізімін жинауға болар еді.

Дереккөздер

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

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

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

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

Пікірлер (0)

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