Crea un'API di accesso Go con JWT, TOTP e OAuth PKCE

Crea un'API di accesso Go con JWT, TOTP e OAuth PKCE

Un endpoint di accesso smette di essere semplice quando deve gestire l'autenticazione a due fattori, il rinnovo dei token e l'accesso tramite social. Questo tutorial crea un piccolo servizio Go che collega questi elementi: verifica delle password con bcrypt, JWT di breve durata, codici TOTP e un flusso OAuth con codice di autorizzazione protetto da PKCE.

L'esempio è una struttura di partenza didattica, non un sistema di account pronto per la produzione. Gli utenti e lo stato delle transazioni OAuth sono conservati in memoria, quindi scompaiono al riavvio e non vengono condivisi tra le istanze. Inoltre, non include il recupero degli account, endpoint per la gestione delle sessioni né la verifica dell'identità specifica del provider. Queste limitazioni sono importanti: l'autenticazione richiede sessioni persistenti e revocabili, non solo token firmati.

Configura il progetto

Il servizio usa Gin per il routing HTTP, golang-jwt per i JWT, pacchetti bcrypt e TOTP dell'ecosistema Go e golang.org/x/oauth2 per il client OAuth. Gli endpoint e le credenziali del provider OAuth vengono forniti tramite variabili d'ambiente, invece di essere inventati per un provider specifico.

mkdir go-auth-demo
cd go-auth-demo
go mod init example.com/go-auth-demo
go get github.com/gin-gonic/gin github.com/golang-jwt/jwt/v5 golang.org/x/crypto/bcrypt github.com/pquerna/otp golang.org/x/oauth2

Crea i file seguenti. Per un avvio locale rapido, imposta un segreto JWT casuale e lungo e fornisci le impostazioni OAuth per un server di autorizzazione che supporti PKCE. Le route OAuth restituiranno un errore se queste impostazioni non sono presenti.

Crea l'accesso con password e la convalida dei JWT

Il primo file contiene un archivio utenti in memoria volutamente essenziale. Le password sono hash bcrypt, non testo in chiaro. Il JWT contiene solo un subject e una scadenza; è firmato con HS256 e il verificatore richiede esplicitamente questo metodo di firma. L'esempio imposta la durata del token a 15 minuti, seguendo l'approccio dei token di accesso di breve durata descritto nel post della DEV Community.

// main.go
package main

import (
	"crypto/rand"
	"encoding/base64"
	"errors"
	"log"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/pquerna/otp/totp"
	"golang.org/x/crypto/bcrypt"
	"golang.org/x/oauth2"
)

type User struct {
	Email    string
	Password []byte
	TOTP     string
	TwoFA    bool
}

type Store struct {
	mu    sync.Mutex
	users map[string]User
}

func newStore() *Store {
	return &Store{users: make(map[string]User)}
}

func (s *Store) add(email, password string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.users[email] = User{Email: email, Password: hash}
	return nil
}

func (s *Store) get(email string) (User, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[email]
	return u, ok
}

func (s *Store) setTOTP(email, secret string, enabled bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u := s.users[email]
	u.TOTP, u.TwoFA = secret, enabled
	s.users[email] = u
}

func signingKey() ([]byte, error) {
	v := os.Getenv("JWT_SECRET")
	if len(v) < 32 {
		return nil, errors.New("JWT_SECRET must contain at least 32 bytes")
	}
	return []byte(v), nil
}

func issueToken(email string) (string, error) {
	key, err := signingKey()
	if err != nil {
		return "", err
	}
	claims := jwt.RegisteredClaims{
		Subject:   email,
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
		IssuedAt:  jwt.NewNumericDate(time.Now()),
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return t.SignedString(key)
}

func validToken(raw string) (string, error) {
	key, err := signingKey()
	if err != nil {
		return "", err
	}
	t, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
		if t.Method != jwt.SigningMethodHS256 {
			return nil, errors.New("unexpected signing algorithm")
		}
		return key, nil
	})
	if err != nil {
		return "", err
	}
	claims, ok := t.Claims.(*jwt.RegisteredClaims)
	if !ok || !t.Valid || claims.Subject == "" {
		return "", errors.New("invalid token claims")
	}
	return claims.Subject, nil
}

func bearerUser(c *gin.Context) (string, bool) {
	header := c.GetHeader("Authorization")
	parts := strings.SplitN(header, " ", 2)
	if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
		return "", false
	}
	email, err := validToken(parts[1])
	return email, err == nil
}

type OAuthTxn struct {
	State    string
	Verifier string
	Expires  time.Time
}

var oauthMu sync.Mutex
var oauthTxns = make(map[string]OAuthTxn)

func randomString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func oauthConfig() (*oauth2.Config, error) {
	keys := []string{"OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET", "OAUTH_AUTH_URL", "OAUTH_TOKEN_URL", "OAUTH_REDIRECT_URL"}
	values := make([]string, len(keys))
	for i, key := range keys {
		values[i] = os.Getenv(key)
		if values[i] == "" {
			return nil, errors.New("OAuth environment configuration is incomplete")
		}
	}
	return &oauth2.Config{
		ClientID: values[0], ClientSecret: values[1],
		Endpoint: oauth2.Endpoint{AuthURL: values[2], TokenURL: values[3]},
		RedirectURL: values[4], Scopes: []string{"openid", "email"},
	}, nil
}

func main() {
	store := newStore()
	if err := store.add("[email protected]", "change-this-password"); err != nil {
		log.Fatal(err)
	}
	r := gin.Default()

	r.POST("/login", func(c *gin.Context) {
		var in struct {
			Email string `json:"email"`
			Password string `json:"password"`
		}
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, ok := store.get(in.Email)
		if !ok || bcrypt.CompareHashAndPassword(u.Password, []byte(in.Password)) != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
			return
		}
		if u.TwoFA {
			c.JSON(http.StatusAccepted, gin.H{"status": "totp_required", "email": u.Email})
			return
		}
		token, err := issueToken(u.Email)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "token unavailable"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"access_token": token, "token_type": "Bearer"})
	})

	r.GET("/me", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"email": email})
	})

	registerTOTP(r, store)
	registerOAuth(r)
	log.Fatal(r.Run(":8080"))
}

L'account demo consente di provare subito la route di accesso; cambia la password prima di usare il servizio per qualsiasi scopo che vada oltre i test locali. Il segreto JWT viene verificato durante la firma o l'analisi dei token, invece di ricorrere silenziosamente a un valore predefinito hard-coded. Un JWT firmato può essere letto da chiunque ne sia in possesso, quindi non inserire informazioni sensibili nelle sue claims.

Aggiungi l'attivazione TOTP e una challenge di accesso

L'attivazione inizia solo per un utente con un token di accesso valido. Il server restituisce un URL otpauth:// che il client può visualizzare come codice QR. Il segreto resta in sospeso finché un codice valido non conferma la configurazione dell'app di autenticazione. Per semplicità, questa demo lo conserva in memoria e non lo cifra a riposo.

La route di accesso precedente restituisce una risposta che richiede TOTP per gli account abilitati, invece di emettere un token. La seconda route verifica nuovamente la password insieme al codice basato sul tempo prima di emetterne uno. In questo modo, la challenge dell'esempio è legata a entrambi i fattori, ma non sostituisce una credenziale di challenge monouso e di breve durata né una rigorosa limitazione delle richieste per utente in un sistema distribuito.

// totp.go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/pquerna/otp"
	"github.com/pquerna/otp/totp"
	"golang.org/x/crypto/bcrypt"
)

func registerTOTP(r *gin.Engine, store *Store) {
	r.POST("/2fa/setup", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		key, err := totp.Generate(totp.GenerateOpts{
			Issuer: "GoAuthDemo", AccountName: email, Digits: otp.DigitsSix,
		})
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create TOTP secret"})
			return
		}
		store.setTOTP(email, key.Secret(), false)
		c.JSON(http.StatusOK, gin.H{"otpauth_url": key.URL()})
	})

	r.POST("/2fa/enable", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		var in struct { Code string `json:"code"` }
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, exists := store.get(email)
		if !exists || u.TOTP == "" || !totp.Validate(in.Code, u.TOTP) {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid code"})
			return
		}
		store.setTOTP(email, u.TOTP, true)
		c.JSON(http.StatusOK, gin.H{"status": "enabled"})
	})

	r.POST("/2fa/login", func(c *gin.Context) {
		var in struct {
			Email string `json:"email"`
			Password string `json:"password"`
			Code string `json:"code"`
		}
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, exists := store.get(in.Email)
		if !exists || !u.TwoFA || bcrypt.CompareHashAndPassword(u.Password, []byte(in.Password)) != nil || !totp.Validate(in.Code, u.TOTP) {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials or code"})
			return
		}
		token, err := issueToken(u.Email)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "token unavailable"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"access_token": token, "token_type": "Bearer"})
	})
}

Qui non è presente una route per disabilitare la 2FA: la disattivazione dovrebbe richiedere una nuova autenticazione e un'implementazione completa dovrebbe fornire codici di recupero per chi perde l'accesso all'app di autenticazione. Anche la verifica TOTP richiede una rigorosa limitazione delle richieste. L'esempio della DEV Community suggerisce limiti più severi per i tentativi 2FA rispetto al normale accesso, basati sia sull'IP sia sull'utente o sulla sessione. Un contatore in memoria non sarebbe condiviso tra più istanze del servizio.

Collega il flusso OAuth con codice di autorizzazione e PKCE

L'ultimo file illustra il funzionamento del reindirizzamento e della callback. Ogni richiesta di avvio genera uno state casuale e un verificatore PKCE, quindi li memorizza temporaneamente. La callback controlla lo state prima di scambiare il codice di autorizzazione usando il verificatore. Il pacchetto OAuth di Go documenta sia S256ChallengeOption sia VerifierOption; RFC 9700 raccomanda PKCE per i client con codice di autorizzazione e afferma che i client devono prevenire gli attacchi CSRF.

Questa callback non crea né collega deliberatamente un account: per farlo occorre convalidare la risposta d'identità del provider e applicare una politica accurata di collegamento degli account. In particolare, il post della DEV Community mette in guardia dal collegare automaticamente gli account tramite email, a meno che il provider non confermi che l'indirizzo è verificato. Inoltre, il codice non restituisce al browser i token del provider.

// oauth.go
package main

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

func registerOAuth(r *gin.Engine) {
	r.GET("/oauth/start", func(c *gin.Context) {
		conf, err := oauthConfig()
		if err != nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
			return
		}
		state, err := randomString(32)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "random generation failed"})
			return
		}
		verifier := oauth2.GenerateVerifier()
		oauthMu.Lock()
		oauthTxns[state] = OAuthTxn{State: state, Verifier: verifier, Expires: time.Now().Add(5 * time.Minute)}
		oauthMu.Unlock()
		c.Redirect(http.StatusFound, conf.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)))
	})

	r.GET("/oauth/callback", func(c *gin.Context) {
		conf, err := oauthConfig()
		if err != nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
			return
		}
		state := c.Query("state")
		oauthMu.Lock()
		txn, exists := oauthTxns[state]
		delete(oauthTxns, state)
		oauthMu.Unlock()
		if !exists || txn.State != state || time.Now().After(txn.Expires) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
			return
		}
		tok, err := conf.Exchange(c.Request.Context(), c.Query("code"), oauth2.VerifierOption(txn.Verifier))
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization exchange failed"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"status": "provider authorization received", "token_type": tok.TokenType})
	})
}

Avvia il servizio con un segreto generato localmente e prova l'accesso con password. Poi usa il token bearer restituito per configurare TOTP, scansiona l'URI restituito con un'app di autenticazione, abilita la 2FA con il codice corrente e prova la route di accesso a due fattori.

export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-bytes"
go run .

Prima di andare oltre una demo

  • Rendi persistenti e revocabili le sessioni. Aggiungi la rotazione dei refresh token e memorizza solo l'hash di ciascun refresh token. Tieni traccia delle sessioni affinché gli utenti possano terminarne una o tutte. Un JWT di accesso stateless non può essere revocato in anticipo, a meno che le richieste non verifichino anche lo stato lato server.
  • Usa limiti di frequenza condivisi. Limita l'accesso, l'avvio di OAuth e la verifica 2FA. Le mappe in memoria dell'esempio non si coordinano tra le istanze; il post della DEV Community consiglia un archivio condiviso come Redis quando si scala orizzontalmente.
  • Proteggi i segreti e i token nel browser. Usa una chiave JWT generata in modo sicuro, proteggi i segreti TOTP a riposo ed evita di inserire token negli URL di reindirizzamento. Se usi i cookie, considera le protezioni CSRF.
  • Completa la gestione dell'identità OAuth. Controlla la risposta del provider e lo stato dell'email verificata prima di collegare o creare un account locale. Registra URI di reindirizzamento esatti; RFC 9700 richiede una corrispondenza esatta, con un'eccezione limitata per localhost nelle app native.

Questa struttura di partenza emette token di accesso solo dopo la verifica della password e, se abilitato, di un codice TOTP valido. La callback OAuth verifica state e PKCE prima di accettare la risposta di autorizzazione del provider. Le sessioni persistenti, il recupero e la convalida dell'identità del provider restano i prossimi elementi concreti da implementare.

Fonti: DEV Community, RFC Editor / IETF, Go Packages, OWASP