Урок 35: Безопасность — JWT, хеширование, CORS, rate limiter

Урок 35. Безопасность — JWT, хеширование, CORS, rate limiter

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-security && cd go-security
go mod init go-security

# Устанавливаем зависимости
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
go get golang.org/x/time/rate
go get github.com/google/uuid
go mod tidy

# Структура
mkdir -p internal/auth
mkdir -p internal/middleware
mkdir -p cmd/server

💻 Файл: internal/auth/jwt.go

package auth

import (
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  JWT СЕРВИС                                            ║
// ╚══════════════════════════════════════════════════════════╝

// JWTService — сервис для работы с JWT
type JWTService struct {
    secret        []byte
    accessTTL     time.Duration
    refreshTTL    time.Duration
}

// Claims — пользовательские claims в JWT
type Claims struct {
    jwt.RegisteredClaims
    UserID string `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
}

// TokenPair — пара токенов
type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    ExpiresIn    int64  `json:"expires_in"` // секунды
}

// NewJWTService — конструктор
func NewJWTService(secret string, accessTTL, refreshTTL time.Duration) *JWTService {
    return &JWTService{
        secret:     []byte(secret),
        accessTTL:  accessTTL,
        refreshTTL: refreshTTL,
    }
}

// GenerateTokenPair — создаёт access + refresh токены
func (s *JWTService) GenerateTokenPair(userID, email, role string) (*TokenPair, error) {
    now := time.Now()

    // Access token
    accessClaims := &Claims{
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        uuid.New().String(),
            Subject:   userID,
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
            Issuer:    "go-security",
        },
        UserID: userID,
        Email:  email,
        Role:   role,
    }

    accessToken, err := s.createToken(accessClaims)
    if err != nil {
        return nil, fmt.Errorf("create access token: %w", err)
    }

    // Refresh token (с другими claims)
    refreshClaims := &Claims{
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        uuid.New().String(),
            Subject:   userID,
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(s.refreshTTL)),
            Issuer:    "go-security",
        },
        UserID: userID,
    }

    refreshToken, err := s.createToken(refreshClaims)
    if err != nil {
        return nil, fmt.Errorf("create refresh token: %w", err)
    }

    return &TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        ExpiresIn:    int64(s.accessTTL.Seconds()),
    }, nil
}

// ValidateToken — проверяет токен и возвращает claims
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(
        tokenString,
        &Claims{},
        func(token *jwt.Token) (interface{}, error) {
            // Проверяем алгоритм (защита от подмены алгоритма)
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
            }
            return s.secret, nil
        },
    )

    if err != nil {
        return nil, fmt.Errorf("parse token: %w", err)
    }

    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }

    return claims, nil
}

// RefreshAccessToken — обновляет access token по refresh token
func (s *JWTService) RefreshAccessToken(refreshToken string) (*TokenPair, error) {
    claims, err := s.ValidateToken(refreshToken)
    if err != nil {
        return nil, fmt.Errorf("invalid refresh token: %w", err)
    }

    // Генерируем новую пару (refresh token rotation)
    return s.GenerateTokenPair(claims.UserID, claims.Email, claims.Role)
}

// createToken — создаёт подписанный токен
func (s *JWTService) createToken(claims *Claims) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.secret)
}

💻 Файл: internal/auth/password.go

package auth

import (
    "errors"
    "strings"
    "unicode"

    "golang.org/x/crypto/bcrypt"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  ХЕШИРОВАНИЕ ПАРОЛЕЙ                                   ║
// ╚══════════════════════════════════════════════════════════╝

const (
    BcryptCost = 12 // Стоимость bcrypt (4-31). 12 — хороший баланс
)

// HashPassword — хеширует пароль через bcrypt
func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
    if err != nil {
        return "", err
    }
    return string(bytes), nil
}

// CheckPassword — проверяет пароль с хешем
func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ВАЛИДАЦИЯ ПАРОЛЯ                                      ║
// ╚══════════════════════════════════════════════════════════╝

var (
    ErrPasswordTooShort   = errors.New("password must be at least 8 characters")
    ErrPasswordNoUpper    = errors.New("password must contain at least one uppercase letter")
    ErrPasswordNoLower    = errors.New("password must contain at least one lowercase letter")
    ErrPasswordNoDigit    = errors.New("password must contain at least one digit")
    ErrPasswordNoSpecial  = errors.New("password must contain at least one special character")
    ErrPasswordCommon     = errors.New("password is too common")
)

// commonPasswords — список из 100 самых популярных паролей (сокращён)
var commonPasswords = map[string]bool{
    "password": true, "12345678": true, "qwerty123": true,
    "admin123": true, "letmein": true, "welcome1": true,
}

// ValidatePasswordStrength — проверяет пароль на соответствие требованиям
func ValidatePasswordStrength(password string) error {
    if len(password) < 8 {
        return ErrPasswordTooShort
    }

    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, ch := range password {
        switch {
        case unicode.IsUpper(ch):
            hasUpper = true
        case unicode.IsLower(ch):
            hasLower = true
        case unicode.IsDigit(ch):
            hasDigit = true
        case unicode.IsPunct(ch) || unicode.IsSymbol(ch):
            hasSpecial = true
        }
    }

    if !hasUpper { return ErrPasswordNoUpper }
    if !hasLower { return ErrPasswordNoLower }
    if !hasDigit { return ErrPasswordNoDigit }
    if !hasSpecial { return ErrPasswordNoSpecial }

    // Проверка на распространённые пароли
    if commonPasswords[strings.ToLower(password)] {
        return ErrPasswordCommon
    }

    return nil
}

💻 Файл: internal/middleware/auth.go

package middleware

import (
    "context"
    "net/http"
    "strings"

    "go-security/internal/auth"
)

// ContextKey — тип для ключей контекста
type ContextKey string

const (
    UserClaimsKey ContextKey = "userClaims"
)

// AuthMiddleware — проверяет JWT в заголовке Authorization
func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Извлекаем токен из заголовка
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
                return
            }

            // Ожидаем: "Bearer <token>"
            parts := strings.SplitN(authHeader, " ", 2)
            if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
                http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
                return
            }

            tokenString := parts[1]

            // Валидируем токен
            claims, err := jwtService.ValidateToken(tokenString)
            if err != nil {
                http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
                return
            }

            // Кладём claims в контекст
            ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// RequireRole — middleware для проверки роли
func RequireRole(roles ...string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, ok := r.Context().Value(UserClaimsKey).(*auth.Claims)
            if !ok {
                http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
                return
            }

            for _, role := range roles {
                if claims.Role == role {
                    next.ServeHTTP(w, r)
                    return
                }
            }

            http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
        })
    }
}

// GetClaimsFromContext — хелпер для извлечения claims
func GetClaimsFromContext(ctx context.Context) (*auth.Claims, bool) {
    claims, ok := ctx.Value(UserClaimsKey).(*auth.Claims)
    return claims, ok
}

💻 Файл: internal/middleware/security.go

package middleware

import (
    "net/http"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  SECURITY HEADERS                                       ║
// ╚══════════════════════════════════════════════════════════╝

// SecurityHeadersMiddleware — добавляет защитные заголовки
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("X-XSS-Protection", "1; mode=block")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
        w.Header().Set("Permissions-Policy", "geolocation=(), microphone=()")
        next.ServeHTTP(w, r)
    })
}

// ╔══════════════════════════════════════════════════════════╗
// ║  CORS MIDDLEWARE                                        ║
// ╚══════════════════════════════════════════════════════════╝

// CORSMiddleware — настраиваемый CORS
func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")

            allowed := false
            for _, o := range allowedOrigins {
                if o == "*" || o == origin {
                    allowed = true
                    break
                }
            }

            if allowed {
                w.Header().Set("Access-Control-Allow-Origin", origin)
                w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
                w.Header().Set("Access-Control-Allow-Credentials", "true")
                w.Header().Set("Access-Control-Max-Age", "86400")
            }

            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// ╔══════════════════════════════════════════════════════════╗
// ║  RATE LIMITER (per-IP)                                  ║
// ╚══════════════════════════════════════════════════════════╝

// IPRateLimiter — rate limiter по IP
type IPRateLimiter struct {
    mu       sync.Mutex
    limiters map[string]*rate.Limiter
    rate     rate.Limit
    burst    int
}

// NewIPRateLimiter — создаёт rate limiter
func NewIPRateLimiter(r rate.Limit, burst int) *IPRateLimiter {
    return &IPRateLimiter{
        limiters: make(map[string]*rate.Limiter),
        rate:     r,
        burst:    burst,
    }
}

// Allow — проверяет, разрешён ли запрос с этого IP
func (l *IPRateLimiter) Allow(ip string) bool {
    l.mu.Lock()
    limiter, exists := l.limiters[ip]
    if !exists {
        limiter = rate.NewLimiter(l.rate, l.burst)
        l.limiters[ip] = limiter
    }
    l.mu.Unlock()

    return limiter.Allow()
}

// Cleanup — удаляет старые записи (запускать по таймеру)
func (l *IPRateLimiter) Cleanup() {
    l.mu.Lock()
    defer l.mu.Unlock()
    // В реальности — удалять записи старше N минут
    l.limiters = make(map[string]*rate.Limiter)
}

// RateLimitMiddleware — middleware для rate limiting
func RateLimitMiddleware(limiter *IPRateLimiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := r.RemoteAddr

            if !limiter.Allow(ip) {
                w.Header().Set("Content-Type", "application/json")
                w.Header().Set("Retry-After", "1")
                w.WriteHeader(http.StatusTooManyRequests)
                w.Write([]byte(`{"error":"rate limit exceeded"}`))
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

💻 Файл: cmd/server/main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"

    "go-security/internal/auth"
    "go-security/internal/middleware"
    "golang.org/x/time/rate"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🔒 Сервер безопасности...")

    // Инициализация сервисов
    jwtService := auth.NewJWTService(
        "super-secret-key-change-me-in-production", // В реальности из env!
        15*time.Minute,  // Access token: 15 минут
        7*24*time.Hour,  // Refresh token: 7 дней
    )

    rateLimiter := middleware.NewIPRateLimiter(
        rate.Limit(10), // 10 запросов в секунду
        20,             // Burst: до 20
    )

    mux := http.NewServeMux()

    // ==========================================
    // Публичные эндпоинты
    // ==========================================
    mux.HandleFunc("POST /api/login", func(w http.ResponseWriter, r *http.Request) {
        var req struct {
            Email    string `json:"email"`
            Password string `json:"password"`
        }
        json.NewDecoder(r.Body).Decode(&req)

        // Имитация проверки пользователя (в реальности — БД)
        if req.Email != "admin@example.com" {
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
            return
        }

        // Проверяем пароль (в реальности — сравнить с хешем из БД)
        hash, _ := auth.HashPassword("securePass123!")
        if !auth.CheckPassword(req.Password, hash) {
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
            return
        }

        // Генерируем токены
        pair, err := jwtService.GenerateTokenPair("user-1", req.Email, "admin")
        if err != nil {
            writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "token generation failed"})
            return
        }

        writeJSON(w, http.StatusOK, pair)
    })

    mux.HandleFunc("POST /api/register", func(w http.ResponseWriter, r *http.Request) {
        var req struct {
            Email    string `json:"email"`
            Password string `json:"password"`
        }
        json.NewDecoder(r.Body).Decode(&req)

        // Валидация пароля
        if err := auth.ValidatePasswordStrength(req.Password); err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
            return
        }

        // Хешируем пароль
        hash, err := auth.HashPassword(req.Password)
        if err != nil {
            writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "hashing failed"})
            return
        }

        log.Printf("Новый пользователь: %s (hash: %s...)", req.Email, hash[:20])
        writeJSON(w, http.StatusCreated, map[string]string{"message": "user created"})
    })

    mux.HandleFunc("POST /api/refresh", func(w http.ResponseWriter, r *http.Request) {
        var req struct {
            RefreshToken string `json:"refresh_token"`
        }
        json.NewDecoder(r.Body).Decode(&req)

        pair, err := jwtService.RefreshAccessToken(req.RefreshToken)
        if err != nil {
            writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid refresh token"})
            return
        }

        writeJSON(w, http.StatusOK, pair)
    })

    // ==========================================
    // Защищённые эндпоинты
    // ==========================================
    protectedMux := http.NewServeMux()
    protectedMux.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) {
        claims, _ := middleware.GetClaimsFromContext(r.Context())
        writeJSON(w, http.StatusOK, map[string]any{
            "user_id": claims.UserID,
            "email":   claims.Email,
            "role":    claims.Role,
        })
    })

    // Admin-only эндпоинт
    protectedMux.HandleFunc("GET /api/admin/users", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, []string{"user-1", "user-2", "user-3"})
    })

    // Применяем auth middleware
    protectedHandler := middleware.AuthMiddleware(jwtService)(protectedMux)

    // Admin routes — дополнительная проверка роли
    adminHandler := middleware.RequireRole("admin")(protectedMux)

    mux.Handle("/api/", protectedHandler)
    mux.Handle("/api/admin/", adminHandler)

    // ==========================================
    // Глобальные middleware
    // ==========================================
    handler := middleware.SecurityHeadersMiddleware(mux)
    handler = middleware.CORSMiddleware([]string{"http://localhost:3000"})(handler)
    handler = middleware.RateLimitMiddleware(rateLimiter)(handler)

    log.Println("✅ Сервер на :8080")
    log.Println("Тестовые учётные данные:")
    log.Println("  Email: admin@example.com")
    log.Println("  Password: securePass123!")
    http.ListenAndServe(":8080", handler)
}

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

🧪 Тестирование

# Запуск сервера
go run ./cmd/server/main.go

# 1. Регистрация (проверка валидации пароля)
curl -X POST http://localhost:8080/api/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Weak"}'

# Успешная регистрация
curl -X POST http://localhost:8080/api/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"StrongPass123!"}'

# 2. Логин
curl -X POST http://localhost:8080/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"securePass123!"}'

# Сохраняем токен
TOKEN="eyJ..."  # Подставьте полученный access_token

# 3. Доступ к защищённому эндпоинту
curl http://localhost:8080/api/me \
  -H "Authorization: Bearer $TOKEN"

# 4. Проверка rate limiter (отправить 25 запросов)
for i in {1..25}; do
  curl -s http://localhost:8080/api/me \
    -H "Authorization: Bearer $TOKEN" &
done

📊 Уровни безопасности

УровеньИнструментЗащита от
АутентификацияJWT (access + refresh)Несанкционированный доступ
АвторизацияMiddleware RequireRoleДоступ к чужим ресурсам
Хешированиеbcrypt (cost=12)Утечка паролей из БД
ВалидацияValidatePasswordStrengthBrute-force по слабым паролям
CORSCORSMiddlewareCross-origin атаки
Rate LimitingIPRateLimiterDDoS, brute-force
HeadersSecurityHeadersMiddlewareXSS, clickjacking, MIME-sniffing
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →