Implementare la 2FA in Go
L'autenticazione a due fattori (2FA) rappresenta oggi uno dei meccanismi di sicurezza più efficaci per proteggere gli account utente. Aggiungere un secondo livello di verifica oltre alla classica password riduce drasticamente il rischio di accessi non autorizzati, anche nel caso in cui le credenziali vengano compromesse. In questo articolo vedremo come implementare la 2FA basata su TOTP (Time-based One-Time Password) in un'applicazione scritta in Go, utilizzando lo standard RFC 6238.
Come funziona il TOTP
Il protocollo TOTP genera codici numerici temporanei a partire da un segreto condiviso tra server e client. Il funzionamento si basa su tre elementi fondamentali: un segreto (shared secret) codificato in Base32, il timestamp corrente diviso in intervalli di 30 secondi e l'algoritmo HMAC-SHA1 che, combinando segreto e intervallo temporale, produce un codice a 6 cifre. L'utente, tramite un'app come Google Authenticator o Authy, genera il codice sul proprio dispositivo; il server, conoscendo lo stesso segreto, genera indipendentemente lo stesso codice e li confronta.
Dipendenze del progetto
Per l'implementazione utilizzeremo due librerie esterne: pquerna/otp per la generazione e la validazione dei codici TOTP, e skip2/go-qrcode per produrre i QR code che l'utente scansionerà con la propria app di autenticazione. Inizializziamo il modulo e installiamo le dipendenze:
go mod init twofactor-demo
go get github.com/pquerna/otp/totp
go get github.com/skip2/go-qrcode
Struttura del progetto
Organizzeremo il codice in tre file principali all'interno di una struttura semplice e lineare:
twofactor-demo/
├── main.go
├── auth/
│ └── totp.go
└── store/
└── user.go
Il pacchetto store gestirà i dati utente in memoria, il pacchetto auth conterrà la logica TOTP e il file main.go esporrà gli endpoint HTTP.
Il modello utente
Partiamo dalla definizione della struttura dati che rappresenta un utente. Per semplicità useremo una mappa in memoria, ma in un progetto reale si utilizzerebbe un database.
package store
import (
"fmt"
"sync"
)
// User rappresenta un utente registrato nel sistema.
type User struct {
Username string
Password string
TOTPSecret string
Is2FAEnabled bool
}
// UserStore gestisce la persistenza degli utenti in memoria.
type UserStore struct {
mu sync.RWMutex
users map[string]*User
}
// NewUserStore crea un nuovo store vuoto.
func NewUserStore() *UserStore {
return &UserStore{
users: make(map[string]*User),
}
}
// CreateUser registra un nuovo utente con username e password.
func (s *UserStore) CreateUser(username, password string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[username]; exists {
return fmt.Errorf("l'utente %s esiste già", username)
}
s.users[username] = &User{
Username: username,
Password: password,
}
return nil
}
// GetUser restituisce l'utente corrispondente allo username fornito.
func (s *UserStore) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, exists := s.users[username]
if !exists {
return nil, fmt.Errorf("utente %s non trovato", username)
}
return user, nil
}
// EnableTOTP salva il segreto TOTP e attiva la 2FA per l'utente.
func (s *UserStore) EnableTOTP(username, secret string) error {
s.mu.Lock()
defer s.mu.Unlock()
user, exists := s.users[username]
if !exists {
return fmt.Errorf("utente %s non trovato", username)
}
user.TOTPSecret = secret
user.Is2FAEnabled = true
return nil
}
La struttura UserStore utilizza un sync.RWMutex per garantire l'accesso concorrente sicuro alla mappa degli utenti, un aspetto fondamentale in un server HTTP dove più goroutine gestiscono richieste simultanee.
Logica TOTP
Il cuore dell'implementazione risiede nel pacchetto auth, dove generiamo il segreto, produciamo il QR code e validiamo i codici inseriti dall'utente.
package auth
import (
"bytes"
"encoding/base64"
"fmt"
"image/png"
"github.com/pquerna/otp/totp"
qrcode "github.com/skip2/go-qrcode"
)
// GenerateSecret crea una nuova chiave TOTP associata all'utente.
// Restituisce il segreto in formato Base32 e l'URI otpauth completo.
func GenerateSecret(username, issuer string) (string, string, error) {
// Genera la chiave con i parametri standard RFC 6238
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: username,
})
if err != nil {
return "", "", fmt.Errorf("errore nella generazione del segreto: %w", err)
}
return key.Secret(), key.URL(), nil
}
// GenerateQRCode produce un'immagine QR in formato PNG codificata in Base64
// a partire dall'URI otpauth. Il risultato può essere incorporato direttamente
// in un tag img HTML tramite data URI.
func GenerateQRCode(otpauthURL string) (string, error) {
// Crea il QR code con dimensione 256x256 pixel
qr, err := qrcode.New(otpauthURL, qrcode.Medium)
if err != nil {
return "", fmt.Errorf("errore nella creazione del QR code: %w", err)
}
// Converte l'immagine QR in un buffer PNG
var buf bytes.Buffer
err = png.Encode(&buf, qr.Image(256))
if err != nil {
return "", fmt.Errorf("errore nella codifica PNG: %w", err)
}
// Restituisce la stringa Base64 pronta per l'uso in HTML
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
return fmt.Sprintf("data:image/png;base64,%s", encoded), nil
}
// ValidateCode verifica che il codice TOTP inserito dall'utente
// corrisponda a quello atteso per il segreto fornito.
func ValidateCode(secret, code string) bool {
return totp.Validate(code, secret)
}
La funzione GenerateSecret crea una chiave conforme allo standard, specificando l'issuer (il nome della nostra applicazione) e l'account dell'utente. La funzione GenerateQRCode trasforma l'URI otpauth:// in un'immagine QR, restituendola come stringa Base64 pronta per essere mostrata nel browser. Infine, ValidateCode confronta il codice inviato dall'utente con quello calcolato dal server.
Gli endpoint HTTP
Adesso colleghiamo il tutto in main.go, esponendo tre endpoint: registrazione, attivazione della 2FA e login con verifica del codice.
package main
import (
"encoding/json"
"log"
"net/http"
"twofactor-demo/auth"
"twofactor-demo/store"
)
// Costante che identifica la nostra applicazione nei codici TOTP.
const appIssuer = "TwoFactorDemo"
// Variabile globale per lo store degli utenti.
var userStore = store.NewUserStore()
// RegisterRequest rappresenta il corpo della richiesta di registrazione.
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// EnableTOTPRequest rappresenta la richiesta di attivazione della 2FA.
type EnableTOTPRequest struct {
Username string `json:"username"`
}
// EnableTOTPResponse contiene il QR code e il segreto per l'utente.
type EnableTOTPResponse struct {
Secret string `json:"secret"`
QRCode string `json:"qr_code"`
}
// LoginRequest rappresenta la richiesta di login con codice TOTP.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
// writeJSON è una funzione di utilità per scrivere risposte JSON.
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// handleRegister gestisce la registrazione di un nuovo utente.
func handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "metodo non consentito",
})
return
}
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "corpo della richiesta non valido",
})
return
}
// Crea l'utente nello store
if err := userStore.CreateUser(req.Username, req.Password); err != nil {
writeJSON(w, http.StatusConflict, map[string]string{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusCreated, map[string]string{
"message": "utente registrato con successo",
})
}
// handleEnableTOTP genera il segreto TOTP e il QR code per l'utente.
func handleEnableTOTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "metodo non consentito",
})
return
}
var req EnableTOTPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "corpo della richiesta non valido",
})
return
}
// Verifica che l'utente esista
_, err := userStore.GetUser(req.Username)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": err.Error(),
})
return
}
// Genera il segreto TOTP e l'URI otpauth
secret, otpauthURL, err := auth.GenerateSecret(req.Username, appIssuer)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "impossibile generare il segreto TOTP",
})
return
}
// Genera il QR code come immagine Base64
qrBase64, err := auth.GenerateQRCode(otpauthURL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "impossibile generare il QR code",
})
return
}
// Salva il segreto e attiva la 2FA per l'utente
if err := userStore.EnableTOTP(req.Username, secret); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "impossibile attivare la 2FA",
})
return
}
writeJSON(w, http.StatusOK, EnableTOTPResponse{
Secret: secret,
QRCode: qrBase64,
})
}
// handleLogin gestisce il login verificando password e codice TOTP.
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "metodo non consentito",
})
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "corpo della richiesta non valido",
})
return
}
// Recupera l'utente dallo store
user, err := userStore.GetUser(req.Username)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "credenziali non valide",
})
return
}
// Verifica la password (in produzione usare bcrypt)
if user.Password != req.Password {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "credenziali non valide",
})
return
}
// Se la 2FA è attiva, verifica il codice TOTP
if user.Is2FAEnabled {
if req.TOTPCode == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "codice TOTP richiesto",
})
return
}
if !auth.ValidateCode(user.TOTPSecret, req.TOTPCode) {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "codice TOTP non valido",
})
return
}
}
writeJSON(w, http.StatusOK, map[string]string{
"message": "login effettuato con successo",
})
}
func main() {
// Registra gli handler per ciascun endpoint
http.HandleFunc("/register", handleRegister)
http.HandleFunc("/enable-2fa", handleEnableTOTP)
http.HandleFunc("/login", handleLogin)
log.Println("Server in ascolto su :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Testare l'implementazione
Avviamo il server e testiamo i tre endpoint con curl. Per prima cosa, registriamo un utente:
curl -X POST http://localhost:8080/register \
-H "Content-Type: application/json" \
-d '{"username":"mario","password":"segreto123"}'
Attiviamo la 2FA per quell'utente:
curl -X POST http://localhost:8080/enable-2fa \
-H "Content-Type: application/json" \
-d '{"username":"mario"}'
La risposta conterrà un campo secret e un campo qr_code. Il segreto può essere inserito manualmente nell'app di autenticazione, mentre la stringa Base64 del QR code può essere visualizzata in un browser incollandola come attributo src di un tag img. Una volta configurata l'app, questa inizierà a generare codici a 6 cifre che si aggiornano ogni 30 secondi.
Infine, effettuiamo il login includendo il codice TOTP:
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"mario","password":"segreto123","totp_code":"483921"}'
Sostituendo 483921 con il codice effettivo mostrato dall'app di autenticazione, il server risponderà con il messaggio di login riuscito.
Hashing delle password con bcrypt
Nel codice precedente, per chiarezza, le password sono confrontate in chiaro. In un ambiente di produzione questo è inaccettabile. Go offre un supporto nativo per bcrypt tramite il pacchetto golang.org/x/crypto/bcrypt. Ecco come modificare la registrazione e il login:
package store
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword genera un hash bcrypt della password in chiaro.
func HashPassword(plaintext string) (string, error) {
// Il costo 12 offre un buon compromesso tra sicurezza e prestazioni
hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), 12)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword confronta una password in chiaro con il suo hash bcrypt.
func CheckPassword(hash, plaintext string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
return err == nil
}
Con queste due funzioni, basterà chiamare HashPassword al momento della registrazione e CheckPassword al momento del login, al posto del confronto diretto tra stringhe.
Codici di recupero
Un aspetto spesso trascurato è la gestione dei codici di recupero (recovery codes). Se l'utente perde il dispositivo su cui è configurata l'app di autenticazione, non potrà più accedere al proprio account. Per risolvere il problema, al momento dell'attivazione della 2FA si generano una serie di codici monouso che l'utente deve conservare in un luogo sicuro.
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// GenerateRecoveryCodes crea un insieme di codici di recupero monouso.
// Ciascun codice è una stringa esadecimale di 8 caratteri.
func GenerateRecoveryCodes(count int) ([]string, error) {
codes := make([]string, count)
for i := 0; i < count; i++ {
// Genera 4 byte casuali (producono 8 caratteri esadecimali)
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("errore nella generazione del codice: %w", err)
}
codes[i] = hex.EncodeToString(b)
}
return codes, nil
}
In produzione, questi codici dovrebbero essere salvati come hash (con bcrypt o simili) nel database. Quando l'utente ne usa uno, il codice viene invalidato e non potrà essere riutilizzato. Una pratica comune è generare 10 codici e mostrare all'utente un avviso quando ne restano meno di 3.
Middleware per la protezione delle rotte
In un'applicazione reale, la verifica della 2FA viene tipicamente integrata in un middleware che protegge le rotte riservate. Ecco un esempio di middleware che controlla la presenza di un token di sessione e, se la 2FA è attiva, verifica che la sessione sia stata completamente autenticata:
package main
import (
"context"
"net/http"
)
// Tipo personalizzato per le chiavi di contesto, evita collisioni.
type contextKey string
const userContextKey contextKey = "user"
// RequireAuth è un middleware che verifica l'autenticazione completa.
// Se la 2FA è attiva, la sessione deve essere stata verificata
// anche con il codice TOTP prima di poter accedere alle rotte protette.
func RequireAuth(sessions *SessionStore, users *store.UserStore, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Estrae il token di sessione dal cookie
cookie, err := r.Cookie("session_token")
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "sessione non trovata",
})
return
}
// Recupera la sessione dallo store
session, err := sessions.Get(cookie.Value)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "sessione non valida",
})
return
}
// Verifica che la 2FA sia stata completata
if !session.TwoFactorVerified {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "verifica 2FA richiesta",
})
return
}
// Inserisce l'utente nel contesto della richiesta
ctx := context.WithValue(r.Context(), userContextKey, session.Username)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
Questo pattern separa nettamente il flusso di autenticazione: prima l'utente inserisce username e password, poi, se la 2FA è attiva, viene richiesto il codice TOTP. Solo dopo entrambe le verifiche la sessione viene marcata come completamente autenticata e l'accesso alle risorse protette è consentito.
Considerazioni sulla sicurezza
Nell'implementare la 2FA in produzione ci sono diversi aspetti da tenere presente. Il segreto TOTP deve essere crittografato a riposo nel database, utilizzando ad esempio AES-256-GCM con una chiave gestita tramite un servizio di key management. La validazione del codice TOTP dovrebbe accettare anche il codice dell'intervallo precedente e successivo per tollerare lievi sfasamenti temporali tra server e client: la libreria pquerna/otp gestisce questo aspetto tramite il parametro Skew nelle opzioni di validazione.
È fondamentale implementare un meccanismo di rate limiting sugli endpoint di login e di verifica del codice TOTP. Un attaccante che conosce la password potrebbe tentare di indovinare il codice a 6 cifre con un attacco di forza bruta: con un milione di combinazioni possibili e codici validi per 30 secondi, senza rate limiting il rischio è concreto. Limitare i tentativi a 5 per finestra temporale di 30 secondi rende l'attacco impraticabile.
Infine, tutte le comunicazioni devono avvenire tramite HTTPS. Il segreto TOTP e i codici di recupero transitano in chiaro nelle risposte JSON, e senza crittografia del trasporto sarebbero intercettabili da chiunque si trovi sulla stessa rete.
Conclusione
Abbiamo costruito un sistema di autenticazione a due fattori completo in Go, partendo dalla generazione del segreto TOTP fino alla validazione del codice, passando per la generazione del QR code, i codici di recupero e la protezione delle rotte tramite middleware. La libreria standard di Go, combinata con pochi pacchetti esterni ben mantenuti, offre tutto il necessario per implementare la 2FA in modo robusto e sicuro. Il passo successivo è integrare questa logica con un database reale, aggiungere la crittografia dei segreti a riposo e implementare il rate limiting, trasformando questo esempio didattico in un sistema pronto per la produzione.