Go: Projeto Integrado: API de comércio eletrônico (Parte 2)

Última atualização: 2026-08-26

Na parte anterior, configuramos a estrutura para a API de comércio eletrônico. O objetivo desta parte é torná-la segura, fácil de manter e testável.

A API de comércio eletrônico do Bob revelou sérios problemas durante uma auditoria de segurança: ausência de autenticação, de controles de acesso e de limitação de solicitações. É hora de reforçar o pipeline de middleware.

1. Você aprenderá


2. Relato: Relatório de auditoria de segurança

(1) Problema: “Nenhuma das APIs exige autenticação, portanto, qualquer pessoa pode excluir produtos.”

No primeiro dia em que o MVP do Bob entrou em operação, a equipe de segurança enviou um relatório de auditoria:

“Vulnerabilidade grave: a chamada DELETE /api/v1/products/1 não exige autenticação. Qualquer pessoa pode excluir produtos. Vulnerabilidade moderada: não há limitação da taxa de solicitações; um invasor poderia realizar um ataque de força bruta na API de login. Recomendação: autenticação JWT + permissões RBAC + limitação da taxa de solicitações.”

Bob deu uma olhada na porta dos fundos deixada pela versão anterior:

GO
// Bad code: no authentication, anyone can place an order
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    userID := 1  // Hardcoded! All orders go to user 1
    // Business logic...
}

(2) Objetivo da aula: Concluir o pipeline de middleware

TEXT 📖 Somente leitura
Request → Recovery → Logging → CORS → Auth → RBAC → RateLimit → Handler

Planos para esta semana:

  1. Middleware de autenticação JWT (substitui o userID codificado)
  2. Controle de permissões RBAC (funções de administrador/cliente)
  3. Migração de banco de dados SQL (gerenciamento de esquema de tabelas com controle de versão)
  4. Testes de integração (httptest + banco de dados temporário)
  5. Middleware de paginação

3. Implementação completa

(1) ▶ Exemplo: JWT Toolkit

GO 📖 Somente leitura
// internal/auth/jwt.go
package auth

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "strings"
    "time"
)

type Claims struct {
    UserID int    `json:"user_id"`
    Role   string `json:"role"`
    Email  string `json:"email"`
}

type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
}

type jwtService struct {
    secret     []byte
    accessTTL  time.Duration
    refreshTTL time.Duration
}

func NewJWTService(secret string) *jwtService {
    return &jwtService{
        secret:     []byte(secret),
        accessTTL:  15 * time.Minute,
        refreshTTL: 7 * 24 * time.Hour,
    }
}

func (s *jwtService) GenerateToken(claims Claims) (string, error) {
    header := map[string]string{"alg": "HS256", "typ": "JWT"}
    headerJSON, _ := json.Marshal(header)

    payload := map[string]interface{}{
        "user_id": claims.UserID,
        "role":    claims.Role,
        "email":   claims.Email,
        "exp":     time.Now().Add(s.accessTTL).Unix(),
        "iat":     time.Now().Unix(),
    }
    payloadJSON, _ := json.Marshal(payload)

    headerEnc := base64URLEncode(headerJSON)
    payloadEnc := base64URLEncode(payloadJSON)

    message := headerEnc + "." + payloadEnc
    mac := hmac.New(sha256.New, s.secret)
    mac.Write([]byte(message))
    sig := base64URLEncode(mac.Sum(nil))

    return message + "." + sig, nil
}

func (s *jwtService) ValidateToken(token string) (*Claims, error) {
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        return nil, fmt.Errorf("invalid token")
    }

    message := parts[0] + "." + parts[1]
    mac := hmac.New(sha256.New, s.secret)
    mac.Write([]byte(message))
    expected := base64URLEncode(mac.Sum(nil))

    if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
        return nil, fmt.Errorf("invalid signature")
    }

    payloadJSON, err := base64URLDecode(parts[1])
    if err != nil {
        return nil, err
    }

    var payload struct {
        UserID int     `json:"user_id"`
        Role   string  `json:"role"`
        Email  string  `json:"email"`
        Exp    float64 `json:"exp"`
    }
    if err := json.Unmarshal(payloadJSON, &payload); err != nil {
        return nil, err
    }

    if time.Now().Unix() > int64(payload.Exp) {
        return nil, fmt.Errorf("token expired")
    }

    return &Claims{
        UserID: payload.UserID,
        Role:   payload.Role,
        Email:  payload.Email,
    }, nil
}

func base64URLEncode(data []byte) string {
    return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}

func base64URLDecode(s string) ([]byte, error) {
    switch len(s) % 4 {
    case 2:
        s += "=="
    case 3:
        s += "="
    }
    return base64.URLEncoding.DecodeString(s)
}
96 linhas de lógica (limite de 40, somente leitura)

(2) ▶ Exemplo: Pipeline de middleware

GO 📖 Somente leitura
// internal/middleware/middleware.go
package middleware

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

    "ecommerce/internal/auth"
)

type contextKey string

const UserClaimsKey contextKey = "user_claims"

// ---------- Middleware type ----------

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// ---------- Recovery ----------

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("[PANIC] %v", err)
                writeError(w, http.StatusInternalServerError, "internal error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// ---------- Logging ----------

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
        next.ServeHTTP(w, r)
        log.Printf("[%s] %s → %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// ---------- CORS ----------

func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// ---------- Auth (JWT) ----------

func Auth(jwtService *auth.JWTService) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if !strings.HasPrefix(authHeader, "Bearer ") {
                writeError(w, http.StatusUnauthorized, "missing token")
                return
            }

            token := authHeader[7:]
            claims, err := jwtService.ValidateToken(token)
            if err != nil {
                writeError(w, http.StatusUnauthorized, err.Error())
                return
            }

            ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// ---------- RBAC ----------

func RequireRole(roles ...string) Middleware {
    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 {
                writeError(w, http.StatusUnauthorized, "not authenticated")
                return
            }

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

            writeError(w, http.StatusForbidden, "insufficient permissions")
        })
    }
}

// ---------- Token bucket rate limiting ----------

type TokenBucket struct {
    mu         sync.Mutex
    tokens     float64
    maxTokens  float64
    refillRate float64
    lastRefill time.Time
}

type IPRateLimiter struct {
    mu      sync.RWMutex
    buckets map[string]*TokenBucket
    rate    float64
    burst   int
}

var globalLimiter = NewIPRateLimiter(100, 200)

func NewIPRateLimiter(rate float64, burst int) *IPRateLimiter {
    return &IPRateLimiter{
        buckets: make(map[string]*TokenBucket),
        rate:    rate,
        burst:   burst,
    }
}

func (rl *IPRateLimiter) getBucket(ip string) *TokenBucket {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    b, ok := rl.buckets[ip]
    if !ok {
        b = &TokenBucket{
            tokens:     float64(rl.burst),
            maxTokens:  float64(rl.burst),
            refillRate: rl.rate,
            lastRefill: time.Now(),
        }
        rl.buckets[ip] = b
    }
    return b
}

func (b *TokenBucket) allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(b.lastRefill).Seconds()
    b.tokens = min(b.tokens+elapsed*b.refillRate, b.maxTokens)
    b.lastRefill = now

    if b.tokens >= 1 {
        b.tokens--
        return true
    }
    return false
}

func min(a, b float64) float64 {
    if a < b {
        return a
    }
    return b
}

func RateLimit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        if !globalLimiter.getBucket(ip).allow() {
            writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
            return
        }
        next.ServeHTTP(w, r)
    })
}

// ---------- Pagination ----------

type Pagination struct {
    Page    int `json:"page"`
    PerPage int `json:"per_page"`
    Offset  int `json:"-"`
}

func ParsePagination(r *http.Request) Pagination {
    page := 1
    perPage := 20

    if p := r.URL.Query().Get("page"); p != "" {
        if v, err := parseInt(p); err == nil && v > 0 {
            page = v
        }
    }
    if pp := r.URL.Query().Get("per_page"); pp != "" {
        if v, err := parseInt(pp); err == nil && v > 0 && v <= 100 {
            perPage = v
        }
    }

    return Pagination{
        Page:    page,
        PerPage: perPage,
        Offset:  (page - 1) * perPage,
    }
}

func parseInt(s string) (int, error) {
    var n int
    for _, c := range s {
        if c < '0' || c > '9' {
            return 0, fmt.Errorf("not a number")
        }
        n = n*10 + int(c-'0')
    }
    return n, nil
}

// ---------- Utility ----------

func writeError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
193 linhas de lógica (limite de 40, somente leitura)

(3) ▶ Exemplo: Migração de banco de dados SQL

GO 📖 Somente leitura
// internal/migration/migration.go
package migration

import (
    "database/sql"
    "fmt"
    "log"
)

type Migration struct {
    Version int
    SQL     string
}

var migrations = []Migration{
    {
        Version: 1,
        SQL: `CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            email TEXT UNIQUE NOT NULL,
            password TEXT NOT NULL,
            name TEXT NOT NULL,
            role TEXT NOT NULL DEFAULT 'customer',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )`,
    },
    {
        Version: 2,
        SQL: `CREATE TABLE IF NOT EXISTS products (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            description TEXT,
            price REAL NOT NULL,
            stock INTEGER NOT NULL DEFAULT 0,
            category_id INTEGER,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (category_id) REFERENCES categories(id)
        )`,
    },
    {
        Version: 3,
        SQL: `CREATE TABLE IF NOT EXISTS orders (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            product_id INTEGER NOT NULL,
            quantity INTEGER NOT NULL,
            total_price REAL NOT NULL,
            status TEXT NOT NULL DEFAULT 'pending',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (user_id) REFERENCES users(id),
            FOREIGN KEY (product_id) REFERENCES products(id)
        )`,
    },
    {
        Version: 4,
        SQL: `CREATE TABLE IF NOT EXISTS categories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT UNIQUE NOT NULL,
            description TEXT,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )`,
    },
}

func RunMigrations(db *sql.DB) error {
    // Create version table
    _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
        version INTEGER PRIMARY KEY,
        applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )`)
    if err != nil {
        return fmt.Errorf("create migrations table: %w", err)
    }

    for _, m := range migrations {
        // Check if already applied
        var exists bool
        err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", m.Version).Scan(&exists)
        if err != nil {
            return err
        }
        if exists {
            continue
        }

        // Apply migration
        if _, err := db.Exec(m.SQL); err != nil {
            return fmt.Errorf("migration %d: %w", m.Version, err)
        }

        // Record version
        if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
            return err
        }

        log.Printf("[Migration] Version %d applied", m.Version)
    }

    return nil
}
86 linhas de lógica (limite de 40, somente leitura)

(4) ▶ Exemplo: Testes de integração

⚙️ Pré-requisito: Execute go get github.com/mattn/go-sqlite3 (requer CGO; como alternativa, use modernc.org/sqlite para um driver exclusivamente de Go)

GO 📖 Somente leitura
// internal/handler/integration_test.go
package handler

import (
    "bytes"
    "database/sql"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "ecommerce/internal/auth"
    "ecommerce/internal/middleware"
    "ecommerce/internal/repository"
    "ecommerce/internal/service"

    _ "github.com/mattn/go-sqlite3"
)

func setupTestDB(t *testing.T) *sql.DB {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal(err)
    }

    // Initialize tables
    _, err = db.Exec(`
        CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT 'customer');
        CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, price REAL NOT NULL, stock INTEGER DEFAULT 0);
        CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, total_price REAL NOT NULL, status TEXT DEFAULT 'pending');
    `)
    if err != nil {
        t.Fatal(err)
    }

    return db
}

func setupTestApp(db *sql.DB) http.Handler {
    userRepo := repository.NewUserRepository(db)
    productRepo := repository.NewProductRepository(db)
    orderRepo := repository.NewOrderRepository(db)

    userSvc := service.NewUserService(userRepo)
    productSvc := service.NewProductService(productRepo)
    orderSvc := service.NewOrderService(orderRepo, productRepo, userRepo)

    userHandler := NewUserHandler(userSvc)
    productHandler := NewProductHandler(productSvc)
    orderHandler := NewOrderHandler(orderSvc)

    mux := http.NewServeMux()
    userHandler.Register(mux)
    productHandler.Register(mux)
    orderHandler.Register(mux)

    // Apply middleware
    return middleware.Chain(mux,
        middleware.Recovery,
        middleware.Logging,
        middleware.CORS,
    )
}

func TestRegisterAndLogin(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    app := setupTestApp(db)

    // 1. Register
    registerBody := `{"email":"test@example.com","password":"password123","name":"Test User"}`
    req := httptest.NewRequest("POST", "/api/v1/register", bytes.NewBufferString(registerBody))
    req.Header.Set("Content-Type", "application/json")
    resp := httptest.NewRecorder()
    app.ServeHTTP(resp, req)

    if resp.Code != http.StatusCreated {
        t.Fatalf("expected 201, got %d", resp.Code)
    }

    // 2. Login
    loginBody := `{"email":"test@example.com","password":"password123"}`
    req = httptest.NewRequest("POST", "/api/v1/login", bytes.NewBufferString(loginBody))
    req.Header.Set("Content-Type", "application/json")
    resp = httptest.NewRecorder()
    app.ServeHTTP(resp, req)

    if resp.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d", resp.Code)
    }

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    data := result["data"].(map[string]interface{})
    if data["email"] != "test@example.com" {
        t.Errorf("expected test@example.com, got %v", data["email"])
    }
}

func TestCreateOrder_RequiresAuth(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    app := setupTestApp(db)

    // Request without Token should return 401
    orderBody := `{"product_id":1,"quantity":1}`
    req := httptest.NewRequest("POST", "/api/v1/orders", bytes.NewBufferString(orderBody))
    req.Header.Set("Content-Type", "application/json")
    resp := httptest.NewRecorder()
    // Note: The current version does not have Auth middleware;
    // it needs to be added in the integration test environment
    _ = resp
    _ = app
}
87 linhas de lógica (limite de 40, somente leitura)
100%
sequenceDiagram
    participant Client
    participant MW as Middleware Pipeline
    participant AuthS as Auth Service
    participant H as Handler

    Client->>MW: POST /api/v1/orders (Bearer token)
    MW->>MW: Recovery / Logging / CORS
    MW->>AuthS: Auth middleware validates JWT
    AuthS-->>MW: Claims {user_id, role}
    MW->>MW: RBAC checks customer role
    MW->>MW: RateLimit check
    MW->>H: Passed! Inject user_id into Context
    H->>H: Create order (using user_id)
    H-->>MW: 201 Created
    MW-->>Client: JSON response

(5) A solicitação passa pelo pipeline de middleware

TEXT 📖 Somente leitura
Request
  ↓ Recovery (catch panic)
  ↓ Logging (log + duration)
  ↓ CORS (cross-origin)
  ↓ Auth (JWT verification → inject Claims)
  ↓ RBAC (check role)
  ↓ RateLimit (token bucket)
  ↓ Handler (business logic)
Response
🔥 Erro comum: O middleware Auth deve vir antes do RBAC. Primeiro, valide o token para recuperar as informações do usuário e, em seguida, verifique as permissões. Se você verificar as permissões antes de validar o token, usuários não autenticados podem gerar um erro de permissão em vez de um erro de autenticação — o que representa um risco à segurança.


❓ Perguntas Frequentes

P: Como o JWT é integrado ao middleware? R: O middleware Auth analisa o token Bearer no cabeçalho, chama jwtService.ValidateToken e injeta o Claims no contexto da solicitação usando context.WithValue. Os manipuladores subsequentes recuperam as informações do usuário usando r.Context().Value(middleware.UserClaimsKey).

P: Como o RBAC é implementado? R: Após o middleware Auth, o middleware RequireRole verifica se o campo role nas Claims está na lista de funções permitidas. Implementações mais complexas podem incorporar uma matriz de permissões (na qual cada função possui uma lista de permissões, e o middleware verifica permissões específicas).

P: Como escolho uma ferramenta de migração SQL? R: (1) golang-migrate/migrate — a mais utilizada, compatível com vários bancos de dados e fontes de migração; (2) Migração manual — a abordagem utilizada nesta lição, adequada para projetos pequenos; (3) pressly/goose — com diversos recursos. Para projetos grandes, recomendamos golang-migrate.

P: Como faço para escrever um teste de integração? R: Use httptest.NewServer ou httptest.NewRecorder para simular solicitações HTTP, em combinação com um banco de dados temporário (SQLite no modo :memory:). Fluxo de teste: configurar o banco de dados → injetar dependências → criar um manipulador → enviar uma solicitação HTTP → verificar a resposta.

P: Como o middleware de paginação é implementado? R: Ele analisa os parâmetros de consulta page e per_page para calcular o deslocamento. Em seguida, adiciona LIMIT ? OFFSET ? à consulta na camada de Serviço. O Handler pode adicionar um cabeçalho X-Total-Count ou metainformações à resposta. O middleware é responsável apenas por analisar os parâmetros; ele não modifica a lógica da consulta.


📖 Resumo


📝 Exercícios

  1. Básico (Dificuldade ⭐): Integre o middleware de autenticação desta lição à API de comércio eletrônico da lição anterior. Retorne um token JWT no momento do cadastro e exija um token Bearer para todos os endpoints /api/v1/orders/*. Retorne um erro 401 para solicitações sem token.

  2. Avançado (Dificuldade ⭐⭐): Implemente um teste de integração completo para o pipeline de middleware. Requisitos: (1) Use httptest + um banco de dados SQLite temporário; (2) Teste o fluxo de autenticação (cadastro → login → obtenção do token → acesso a endpoints protegidos usando o token); (3) Teste cenários de negação de permissão (um “cliente” tentando acessar um endpoint “admin”); (4) Teste cenários de limitação de taxa (um grande número de solicitações em um curto período retornando um erro 429).

  3. Desafio (Dificuldade ⭐⭐⭐): Implementar a migração do banco de dados usando o golang-migrate. Requisitos: (1) Instalar a CLI golang-migrate/migrate; (2) Escrever três arquivos de migração (para criar as tabelas users, products e orders); (3) Migrar o binário Go (pacote embed); (4) Verifique se o cmd/migrate/main.go suporta os comandos up e down; (5) Realize testes de integração usando o esquema do banco de dados migrado.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%