Tarkibga o'tish

Login / Signup endpointlari

Bu darsda autentifikatsiyaning ikki asosiy endpointini yozamiz:

  • POST /auth/signup — yangi foydalanuvchini ro‘yxatdan o‘tkazish;
  • POST /auth/login — email va parolni tekshirish.

Kod birinchi qarashda oddiy ko‘rinadi: ma’lumotni olish, bazaga yozish, keyin solishtirish. Lekin har bir qadamda xavfsizlikka oid nozik jihatlar bor. Ularni birma-bir ko‘rib chiqamiz.

Login muvaffaqiyatli bo‘lganda klientga token berilishi kerak. Token yaratish keyingi darsda qo‘shiladi. Hozircha login faqat foydalanuvchi ma’lumotini qaytaradi.

Foydalanuvchi modeli

CREATE TABLE users (
    id            BIGSERIAL PRIMARY KEY,
    email         TEXT        NOT NULL UNIQUE,
    name          TEXT        NOT NULL,
    password_hash TEXT        NOT NULL,
    role          TEXT        NOT NULL DEFAULT 'reader',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
type User struct {
    ID           int64
    Email        string
    Name         string
    PasswordHash string
    Role         string
    CreatedAt    time.Time
}

User — ichki model. U hech qachon to‘g‘ridan-to‘g‘ri JSON’ga aylantirilmaydi. Klientga alohida UserResponse DTO’si qaytariladi. Sababi Response struct formatlash darsida tushuntirilgan.

Bu darsda kodni bazasiz ishga tushirish uchun foydalanuvchilar xotirada saqlanadi. Dars oxirida PostgreSQL uchun mos repository metodlari ham keltirilgan.

1. Email’ni normallashtirish

[email protected], [email protected] va [email protected] — bitta email. Ular turli yozuv sifatida saqlansa, bitta odam uchta akkaunt ochishi mumkin, login esa faqat ro‘yxatdan o‘tishda qanday yozilgan bo‘lsa, shunday ishlaydi.

func normalizeEmail(s string) string {
    return strings.ToLower(strings.TrimSpace(s))
}

Email ham ro‘yxatdan o‘tishda, ham login paytida normallashtiriladi. Normallashtirish validatsiyadan oldin bajariladi. Aks holda email qoidasi " [email protected] " ko‘rinishidagi qiymatni bo‘shliqlar sababli rad etadi.

2. Ro‘yxatdan o‘tish

type signupRequest struct {
    Email    string `json:"email" validate:"required,email,max=254"`
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Password string `json:"password" validate:"required,min=8,max=72"`
}

Signup handler qadamlari:

  1. So‘rov tanasini o‘qish va validatsiya qilish.
  2. Email’ni normallashtirish.
  3. Parolni bcrypt bilan xeshlash.
  4. Foydalanuvchini saqlash. Email band bo‘lsa — 409 Conflict.
  5. 201 Created va foydalanuvchi ma’lumotini qaytarish. Parol va xesh qaytarilmaydi.

Warning

role maydoni so‘rovdan o‘qilmaydi. Aks holda istalgan odam "role": "admin" yuborib, o‘zini administrator qilib oladi. Bunday zaiflik mass assignment deb ataladi. Yangi foydalanuvchi har doim eng past huquqli rolni oladi. Rolni faqat administrator alohida endpoint orqali o‘zgartiradi.

Email band ekanini aytish kerakmi?

"Bu email allaqachon ro‘yxatdan o‘tgan" degan xabar qulay, lekin u ma’lumot sizdiradi. Hujumchi signup endpointi orqali qaysi emaillar tizimda borligini tekshirib chiqishi mumkin. Bu hujum user enumeration (foydalanuvchilarni sanab chiqish) deyiladi.

Ikki yondashuv bor:

  • Qulaylik birinchi o‘rinda. 409 Conflict qaytariladi. Aksariyat ilovalar shunday qiladi. Qo‘shimcha himoya sifatida signup so‘rovlari soni cheklanadi.
  • Maxfiylik birinchi o‘rinda. Har doim bir xil javob qaytadi: "Emailingizga xat yubordik". Email yangi bo‘lsa, tasdiqlash havolasi yuboriladi. Band bo‘lsa, "Sizda allaqachon akkaunt bor" degan xat boradi. Bu usul tibbiyot yoki moliya kabi nozik sohalarda ishlatiladi.

Bu darsda birinchi yondashuv ishlatiladi. Login’da esa ma’lumot sizib chiqmasligi majburiy.

3. Tizimga kirish

type loginRequest struct {
    Email    string `json:"email" validate:"required"`
    Password string `json:"password" validate:"required"`
}

Login’dagi validatsiya signup’dagidan yumshoqroq. Unda min=8 qoidasi yo‘q. Nima uchun? Parol talablari vaqt o‘tishi bilan kuchaytirilishi mumkin. Eski, qisqaroq parolli foydalanuvchilar ham tizimga kira olishi kerak.

Bir xil xato xabari

// ❌ Ma’lumot sizdiradi
if user == nil {
    return "bunday email topilmadi"
}
if !checkPassword(...) {
    return "parol noto‘g‘ri"
}

// ✅ Bir xil javob
return "email yoki parol noto‘g‘ri"

Birinchi variantda hujumchi xabarga qarab email mavjudligini biladi. Ikkinchisida esa ikkala holat bir xil ko‘rinadi.

Vaqt bo‘yicha hujum (timing attack)

Xabar bir xil bo‘lsa ham, javob vaqti ma’lumot sizdirishi mumkin:

Email topilmadi           → darhol javob          ~1 ms
Email bor, parol noto‘g‘ri → bcrypt tekshiruvi    ~70 ms

Hujumchi javob vaqtini o‘lchab, qaysi emaillar mavjudligini baribir aniqlaydi. Himoya usuli — foydalanuvchi topilmasa ham bcrypt tekshiruvini bajarish. Buning uchun oldindan tayyorlangan soxta xesh ishlatiladi:

var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password"), bcryptCost)

user, err := users.ByEmail(ctx, email)
if err != nil {
    // Foydalanuvchi yo‘q, lekin vaqt baribir sarflanadi
    bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
    return ErrInvalidCredentials
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
    return ErrInvalidCredentials
}

Endi ikkala holat taxminan bir xil vaqt oladi.

To‘liq kod

main.go
package main

import (
    "context"
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "strings"
    "sync"
    "time"

    "github.com/go-playground/validator/v10"
    "golang.org/x/crypto/bcrypt"
)

const bcryptCost = 12

var (
    ErrEmailTaken         = errors.New("email band")
    ErrNotFound           = errors.New("foydalanuvchi topilmadi")
    ErrInvalidCredentials = errors.New("email yoki parol noto‘g‘ri")
)

// ---------- Model ----------

type User struct {
    ID           int64
    Email        string
    Name         string
    PasswordHash string
    Role         string
    CreatedAt    time.Time
}

type UserResponse struct {
    ID        int64     `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    Role      string    `json:"role"`
    CreatedAt time.Time `json:"created_at"`
}

func toResponse(u User) UserResponse {
    return UserResponse{ID: u.ID, Email: u.Email, Name: u.Name, Role: u.Role, CreatedAt: u.CreatedAt}
}

// ---------- Repository (xotirada) ----------

type UserRepo struct {
    mu      sync.RWMutex
    byEmail map[string]User
    nextID  int64
}

func NewUserRepo() *UserRepo {
    return &UserRepo{byEmail: make(map[string]User), nextID: 1}
}

func (r *UserRepo) Create(ctx context.Context, u User) (User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.byEmail[u.Email]; exists {
        return User{}, ErrEmailTaken
    }
    u.ID = r.nextID
    u.CreatedAt = time.Now().UTC()
    r.nextID++
    r.byEmail[u.Email] = u
    return u, nil
}

func (r *UserRepo) ByEmail(ctx context.Context, email string) (User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    u, ok := r.byEmail[email]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

// ---------- Service ----------

type AuthService struct {
    users     *UserRepo
    dummyHash []byte
}

func NewAuthService(users *UserRepo) *AuthService {
    dummy, err := bcrypt.GenerateFromPassword([]byte("timing-attack-himoyasi"), bcryptCost)
    if err != nil {
        panic(err)
    }
    return &AuthService{users: users, dummyHash: dummy}
}

func normalizeEmail(s string) string {
    return strings.ToLower(strings.TrimSpace(s))
}

func (s *AuthService) Signup(ctx context.Context, email, name, password string) (User, error) {
    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
    if err != nil {
        return User{}, err
    }

    return s.users.Create(ctx, User{
        Email:        normalizeEmail(email),
        Name:         strings.TrimSpace(name),
        PasswordHash: string(hash),
        Role:         "reader", // rol hech qachon so‘rovdan olinmaydi
    })
}

func (s *AuthService) Login(ctx context.Context, email, password string) (User, error) {
    user, err := s.users.ByEmail(ctx, normalizeEmail(email))
    if errors.Is(err, ErrNotFound) {
        bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
        return User{}, ErrInvalidCredentials
    }
    if err != nil {
        return User{}, err
    }

    if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
        return User{}, ErrInvalidCredentials
    }
    return user, nil
}

// ---------- HTTP ----------

type signupRequest struct {
    Email    string `json:"email" validate:"required,email,max=254"`
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Password string `json:"password" validate:"required,min=8,max=72"`
}

type loginRequest struct {
    Email    string `json:"email" validate:"required"`
    Password string `json:"password" validate:"required,max=72"`
}

type Handler struct {
    auth     *AuthService
    validate *validator.Validate
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, code, msg string) {
    writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
}

func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        writeError(w, http.StatusBadRequest, "bad_request", "JSON noto‘g‘ri")
        return false
    }
    return true
}

func (h *Handler) valid(w http.ResponseWriter, v any) bool {
    if err := h.validate.Struct(v); err != nil {
        writeError(w, http.StatusUnprocessableEntity, "validation_failed", "ma’lumotlar noto‘g‘ri")
        return false
    }
    return true
}

func (h *Handler) signup(w http.ResponseWriter, r *http.Request) {
    var req signupRequest
    if !decode(w, r, &req) {
        return
    }
    req.Email = normalizeEmail(req.Email) // validatsiyadan oldin
    if !h.valid(w, req) {
        return
    }

    user, err := h.auth.Signup(r.Context(), req.Email, req.Name, req.Password)
    switch {
    case errors.Is(err, ErrEmailTaken):
        writeError(w, http.StatusConflict, "email_taken", "bu email bilan akkaunt mavjud")
        return
    case err != nil:
        log.Printf("signup: %v", err)
        writeError(w, http.StatusInternalServerError, "internal", "ichki server xatosi")
        return
    }

    writeJSON(w, http.StatusCreated, map[string]any{"data": toResponse(user)})
}

func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
    var req loginRequest
    if !decode(w, r, &req) || !h.valid(w, req) {
        return
    }

    user, err := h.auth.Login(r.Context(), req.Email, req.Password)
    switch {
    case errors.Is(err, ErrInvalidCredentials):
        w.Header().Set("WWW-Authenticate", "Bearer")
        writeError(w, http.StatusUnauthorized, "invalid_credentials", err.Error())
        return
    case err != nil:
        log.Printf("login: %v", err)
        writeError(w, http.StatusInternalServerError, "internal", "ichki server xatosi")
        return
    }

    // Keyingi darsda bu yerda access token yaratiladi
    writeJSON(w, http.StatusOK, map[string]any{"data": toResponse(user)})
}

func main() {
    h := &Handler{
        auth:     NewAuthService(NewUserRepo()),
        validate: validator.New(validator.WithRequiredStructEnabled()),
    }

    mux := http.NewServeMux()
    mux.HandleFunc("POST /auth/signup", h.signup)
    mux.HandleFunc("POST /auth/login", h.login)

    log.Println("Server: http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Sinab ko‘rish

curl -X POST http://localhost:8080/auth/signup \
  -d '{"email":"  [email protected] ","name":"Aziza","password":"juda-maxfiy-parol"}'
{"data":{"id":1,"email":"[email protected]","name":"Aziza","role":"reader","created_at":"2026-09-30T10:00:00.123456Z"}}

Email kichik harflarga o‘tkazildi va bo‘shliqlar olib tashlandi. Javobda parol ham, xesh ham yo‘q.

Rolni o‘zgartirishga urinamiz:

curl -X POST http://localhost:8080/auth/signup \
  -d '{"email":"[email protected]","name":"X","password":"12345678","role":"admin"}'
{"error":{"code":"bad_request","message":"JSON noto‘g‘ri"}}

DisallowUnknownFields tufayli notanish role maydoni rad etildi. Bu himoyaning qo‘shimcha qatlami. Asosiy himoya esa service’da: rol har doim reader qilib belgilanadi.

Takroriy ro‘yxatdan o‘tish:

curl -X POST http://localhost:8080/auth/signup \
  -d '{"email":"[email protected]","name":"Aziza 2","password":"boshqa-parol-123"}'
{"error":{"code":"email_taken","message":"bu email bilan akkaunt mavjud"}}

Login:

curl -X POST http://localhost:8080/auth/login -d '{"email":"[email protected]","password":"juda-maxfiy-parol"}'
curl -X POST http://localhost:8080/auth/login -d '{"email":"[email protected]","password":"notogri"}'
curl -X POST http://localhost:8080/auth/login -d '{"email":"[email protected]","password":"notogri"}'
{"data":{"id":1,"email":"[email protected]","name":"Aziza","role":"reader","created_at":"2026-09-30T10:00:00.123456Z"}}
{"error":{"code":"invalid_credentials","message":"email yoki parol noto‘g‘ri"}}
{"error":{"code":"invalid_credentials","message":"email yoki parol noto‘g‘ri"}}

Oxirgi ikki javob bir xil. Hujumchi [email protected] tizimda yo‘qligini javobdan ham, javob vaqtidan ham bila olmaydi.

Vaqtni tekshirib ko‘ring:

curl -o /dev/null -s -w "%{time_total}\n" -X POST http://localhost:8080/auth/login \
  -d '{"email":"[email protected]","password":"notogri"}'
curl -o /dev/null -s -w "%{time_total}\n" -X POST http://localhost:8080/auth/login \
  -d '{"email":"[email protected]","password":"notogri"}'
0.198712
0.196405

PostgreSQL uchun repository

Xotiradagi repository’ni bazaga ulangan versiyaga almashtirish uchun faqat ikkita metod yoziladi:

func (r *PostgresUserRepo) Create(ctx context.Context, u User) (User, error) {
    err := r.pool.QueryRow(ctx,
        `INSERT INTO users (email, name, password_hash, role)
         VALUES ($1, $2, $3, $4)
         RETURNING id, created_at`,
        u.Email, u.Name, u.PasswordHash, u.Role,
    ).Scan(&u.ID, &u.CreatedAt)

    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) && pgErr.Code == "23505" {
        return User{}, ErrEmailTaken
    }
    return u, err
}

func (r *PostgresUserRepo) ByEmail(ctx context.Context, email string) (User, error) {
    var u User
    err := r.pool.QueryRow(ctx,
        `SELECT id, email, name, password_hash, role, created_at FROM users WHERE email = $1`,
        email,
    ).Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.CreatedAt)

    if errors.Is(err, pgx.ErrNoRows) {
        return User{}, ErrNotFound
    }
    return u, err
}

Email bandligini oldindan SELECT bilan tekshirish o‘rniga to‘g‘ridan-to‘g‘ri INSERT qilinadi. Takrorlanish UNIQUE cheklovi orqali aniqlanadi. Bu usul bir vaqtda kelgan ikki so‘rovda ham to‘g‘ri ishlaydi. Tafsilotlar PostgreSQL bilan bog‘lanish darsida.

Xulosa

  • Email ro‘yxatdan o‘tishda ham, login’da ham normallashtiriladi.
  • Parol faqat bcrypt xeshi ko‘rinishida saqlanadi. Javobda parol ham, xesh ham qaytmaydi.
  • Rol va boshqa imtiyozli maydonlar so‘rovdan olinmaydi.
  • Login har qanday muvaffaqiyatsizlikda bitta umumiy xabar qaytaradi.
  • Foydalanuvchi topilmasa ham bcrypt tekshiruvi bajariladi. Shunda javob vaqti ma’lumot sizdirmaydi.
  • Email noyobligi baza darajasida UNIQUE cheklovi bilan kafolatlanadi.

Keyingi darsda login muvaffaqiyatli bo‘lganda JWT access token yaratish va uni tekshirishni o‘rganamiz.