Usare JWT in Go senza framework

Mattepuffo's logo
Usare JWT in Go senza framework

Usare JWT in Go senza framework

In un precedente articolo abbiamo visto come creare una web api in Go senza framework.

In questo articolo vediamo come usare JWT per l'autenticazione e la protezione delle rotte.

Queste le librerie che ci servono:

go get -u github.com/golang-jwt/jwt/v5
go get -u golang.org/x/crypto/bcrypt
go get -u github.com/go-sql-driver/mysql

Date anche questo comando:

go mod tidy

Questa la tabella che ho usato per salvare gli utenti:

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL
);

Qui sotto il codice; ho messo tutto insieme per comodità, ma sarebbe meglio dividerlo:

package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

var jwtSecret = []byte("tua-chiave-segreta-super-sicura")

type Persona struct {
	ID    int    `json:"id"`
	Email string `json:"email"`
}

type User struct {
	ID       int    `json:"id"`
	Username string `json:"username"`
	Password string `json:"-"`
}

type LoginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type LoginResponse struct {
	Token string `json:"token"`
	User  User   `json:"user"`
}

type Response struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
}

type Claims struct {
	UserID   int    `json:"user_id"`
	Username string `json:"username"`
	jwt.RegisteredClaims
}

var db *sql.DB

func initDB() error {
	var err error
	dsn := "root:9211@tcp(localhost:3306)/test"

	db, err = sql.Open("mysql", dsn)
	if err != nil {
		return fmt.Errorf("errore apertura database: %v", err)
	}

	if err = db.Ping(); err != nil {
		return fmt.Errorf("errore connessione database: %v", err)
	}

	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(5)

	log.Println("Connessione al database MariaDB riuscita")
	return nil
}

// Genera un token JWT
func generateJWT(userID int, username string) (string, error) {
	claims := Claims{
		UserID:   userID,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "api-persone",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

func validateJWT(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("metodo di firma non valido")
		}
		return jwtSecret, nil
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		return claims, nil
	}

	return nil, fmt.Errorf("token non valido")
}

func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if authHeader == "" {
			respondJSON(w, http.StatusUnauthorized, Response{
				Success: false,
				Error:   "Token di autenticazione mancante",
			})
			return
		}

		tokenParts := strings.Split(authHeader, " ")
		if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
			respondJSON(w, http.StatusUnauthorized, Response{
				Success: false,
				Error:   "Formato token non valido",
			})
			return
		}

		claims, err := validateJWT(tokenParts[1])
		if err != nil {
			respondJSON(w, http.StatusUnauthorized, Response{
				Success: false,
				Error:   "Token non valido o scaduto",
			})
			return
		}

		r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID))
		r.Header.Set("X-Username", claims.Username)

		next(w, r)
	}
}

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

func loginHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

	if r.Method == "OPTIONS" {
		w.WriteHeader(http.StatusOK)
		return
	}

	if r.Method != "POST" {
		respondJSON(w, http.StatusMethodNotAllowed, Response{
			Success: false,
			Error:   "Solo POST è supportato",
		})
		return
	}

	var loginReq LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "JSON non valido",
		})
		return
	}

	var user User
	var hashedPassword string
	err := db.QueryRow("SELECT id, username, password FROM users WHERE username = ?", loginReq.Username).Scan(&user.ID, &user.Username, &hashedPassword)

	if err == sql.ErrNoRows {
		respondJSON(w, http.StatusUnauthorized, Response{
			Success: false,
			Error:   "Credenziali non valide",
		})
		return
	}

	if err != nil {
		log.Printf("Errore query: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore del server",
		})
		return
	}

	if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(loginReq.Password)); err != nil {
		respondJSON(w, http.StatusUnauthorized, Response{
			Success: false,
			Error:   "Credenziali non valide",
		})
		return
	}

	token, err := generateJWT(user.ID, user.Username)
	if err != nil {
		log.Printf("Errore generazione token: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante la generazione del token",
		})
		return
	}

	respondJSON(w, http.StatusOK, Response{
		Success: true,
		Data: LoginResponse{
			Token: token,
			User:  user,
		},
	})
}

func registerHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

	if r.Method == "OPTIONS" {
		w.WriteHeader(http.StatusOK)
		return
	}

	if r.Method != "POST" {
		respondJSON(w, http.StatusMethodNotAllowed, Response{
			Success: false,
			Error:   "Solo POST è supportato",
		})
		return
	}

	var loginReq LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "JSON non valido",
		})
		return
	}

	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(loginReq.Password), bcrypt.DefaultCost)
	if err != nil {
		log.Printf("Errore hash password: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante la registrazione",
		})
		return
	}

	result, err := db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", loginReq.Username, string(hashedPassword))
	if err != nil {
		log.Printf("Errore insert: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Username già esistente o errore del server",
		})
		return
	}

	userID, _ := result.LastInsertId()

	respondJSON(w, http.StatusCreated, Response{
		Success: true,
		Data: map[string]interface{}{
			"id":       userID,
			"username": loginReq.Username,
			"message":  "Registrazione completata con successo",
		},
	})
}

func personeHandler(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 == "OPTIONS" {
		w.WriteHeader(http.StatusOK)
		return
	}

	path := strings.TrimPrefix(r.URL.Path, "/persone")

	switch r.Method {
	case "GET":
		if path == "" || path == "/" {
			handleGetAllPersone(w, r)
		} else {
			id := strings.TrimPrefix(path, "/")
			handleGetPersona(w, r, id)
		}
	case "POST":
		handleCreatePersona(w, r)
	case "PUT":
		id := strings.TrimPrefix(path, "/")
		handleUpdatePersona(w, r, id)
	case "DELETE":
		id := strings.TrimPrefix(path, "/")
		handleDeletePersona(w, r, id)
	default:
		respondJSON(w, http.StatusMethodNotAllowed, Response{
			Success: false,
			Error:   "Metodo non supportato",
		})
	}
}

func handleGetAllPersone(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query("SELECT id, email FROM persone ORDER BY id")
	if err != nil {
		log.Printf("Errore query: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante il recupero dei dati",
		})
		return
	}
	defer rows.Close()

	persone := []Persona{}
	for rows.Next() {
		var p Persona
		if err := rows.Scan(&p.ID, &p.Email); err != nil {
			log.Printf("Errore scan: %v", err)
			continue
		}
		persone = append(persone, p)
	}

	respondJSON(w, http.StatusOK, Response{Success: true, Data: persone})
}

func handleGetPersona(w http.ResponseWriter, r *http.Request, id string) {
	var p Persona
	err := db.QueryRow("SELECT id, email FROM persone WHERE id = ?", id).Scan(&p.ID, &p.Email)

	if err == sql.ErrNoRows {
		respondJSON(w, http.StatusNotFound, Response{
			Success: false,
			Error:   "Persona non trovata",
		})
		return
	}

	if err != nil {
		log.Printf("Errore query: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante il recupero dei dati",
		})
		return
	}

	respondJSON(w, http.StatusOK, Response{Success: true, Data: p})
}

func handleCreatePersona(w http.ResponseWriter, r *http.Request) {
	var p Persona
	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "JSON non valido",
		})
		return
	}

	if p.Email == "" {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "Email obbligatoria",
		})
		return
	}

	result, err := db.Exec("INSERT INTO persone (email) VALUES (?)", p.Email)
	if err != nil {
		log.Printf("Errore insert: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante la creazione",
		})
		return
	}

	lastID, _ := result.LastInsertId()
	p.ID = int(lastID)
	respondJSON(w, http.StatusCreated, Response{Success: true, Data: p})
}

func handleUpdatePersona(w http.ResponseWriter, r *http.Request, id string) {
	var p Persona
	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "JSON non valido",
		})
		return
	}

	if p.Email == "" {
		respondJSON(w, http.StatusBadRequest, Response{
			Success: false,
			Error:   "Email obbligatoria",
		})
		return
	}

	result, err := db.Exec("UPDATE persone SET email = ? WHERE id = ?", p.Email, id)
	if err != nil {
		log.Printf("Errore update: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante l'aggiornamento",
		})
		return
	}

	rowsAffected, _ := result.RowsAffected()
	if rowsAffected == 0 {
		respondJSON(w, http.StatusNotFound, Response{
			Success: false,
			Error:   "Persona non trovata",
		})
		return
	}

	fmt.Sscanf(id, "%d", &p.ID)
	respondJSON(w, http.StatusOK, Response{Success: true, Data: p})
}

func handleDeletePersona(w http.ResponseWriter, r *http.Request, id string) {
	result, err := db.Exec("DELETE FROM persone WHERE id = ?", id)
	if err != nil {
		log.Printf("Errore delete: %v", err)
		respondJSON(w, http.StatusInternalServerError, Response{
			Success: false,
			Error:   "Errore durante l'eliminazione",
		})
		return
	}

	rowsAffected, _ := result.RowsAffected()
	if rowsAffected == 0 {
		respondJSON(w, http.StatusNotFound, Response{
			Success: false,
			Error:   "Persona non trovata",
		})
		return
	}

	respondJSON(w, http.StatusOK, Response{
		Success: true,
		Data:    "Persona eliminata con successo",
	})
}

func rootHandler(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}
	respondJSON(w, http.StatusOK, Response{
		Success: true,
		Data:    "API con autenticazione JWT. Usa /login per autenticarti e /persone per gestire le persone",
	})
}

func main() {
	if err := initDB(); err != nil {
		log.Fatalf("Impossibile connettersi al database: %v", err)
	}
	defer db.Close()

	http.HandleFunc("/", rootHandler)
	http.HandleFunc("/login", loginHandler)
	http.HandleFunc("/register", registerHandler)

	http.HandleFunc("/persone", authMiddleware(personeHandler))
	http.HandleFunc("/persone/", authMiddleware(personeHandler))

	port := ":8080"
	fmt.Printf("Server in ascolto su http://localhost%s\n", port)
	fmt.Println("Endpoints disponibili:")
	fmt.Println("  POST   http://localhost:8080/register  (pubblico)")
	fmt.Println("  POST   http://localhost:8080/login     (pubblico)")
	fmt.Println("  GET    http://localhost:8080/persone   (protetto)")
	fmt.Println("  GET    http://localhost:8080/persone/:id (protetto)")
	fmt.Println("  POST   http://localhost:8080/persone   (protetto)")
	fmt.Println("  PUT    http://localhost:8080/persone/:id (protetto)")
	fmt.Println("  DELETE http://localhost:8080/persone/:id (protetto)")

	log.Fatal(http.ListenAndServe(port, nil))
}

Qui un pò di comandi per i test:

$ curl -X POST http://localhost:8080/register -H "Content-Type: application/json" -d '{"username":"mario","password":"password123"}'
$ curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"username":"mario","password":"password123"}'
$ curl http://localhost:8080/persone -H "Authorization: Bearer TOKEN_SBAGLIATO"
$ curl http://localhost:8080/persone -H "Authorization: Bearer TOKEN_CORRETTO"

Enjoy!


Condividi

Commentami!