Un endpoint de inicio de sesión deja de ser sencillo cuando debe gestionar la autenticación de dos factores, la renovación de tokens y el inicio de sesión con proveedores sociales. En este tutorial crearemos un pequeño servicio en Go que conecta estas piezas: comprobaciones de contraseñas con bcrypt, JWT de corta duración, códigos TOTP y un flujo de código de autorización OAuth protegido con PKCE.
El ejemplo es una estructura básica para aprender, no un sistema de cuentas listo para producción. Los usuarios y el estado de las transacciones OAuth se guardan en memoria, así que desaparecen al reiniciar y no se comparten entre instancias. También se omiten la recuperación de cuentas, los endpoints de gestión de sesiones y la verificación de identidad específica del proveedor. Estas limitaciones son importantes: la autenticación necesita sesiones persistentes y revocables, no solo tokens firmados.
Configurar el proyecto
El servicio usa Gin para el enrutamiento HTTP, golang-jwt para los JWT, paquetes de bcrypt y TOTP del ecosistema Go, y golang.org/x/oauth2 para el cliente OAuth. Los endpoints y las credenciales del proveedor OAuth se obtienen de variables de entorno, en lugar de inventarse para un proveedor específico.
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/oauth2Crea los siguientes archivos. Para ejecutarlo rápidamente en local, configura un secreto JWT aleatorio y largo, y proporciona los ajustes OAuth de un servidor de autorización compatible con PKCE. Las rutas OAuth devolverán un error si faltan esos ajustes.
Crear el inicio de sesión con contraseña y la validación de JWT
El primer archivo contiene un almacén de usuarios en memoria deliberadamente pequeño. Las contraseñas son hashes bcrypt, no texto sin formato. El JWT solo contiene un subject y una fecha de expiración; se firma con HS256, y el verificador exige explícitamente ese método de firma. El ejemplo fija la duración del token en 15 minutos, siguiendo el enfoque de tokens de acceso de corta duración descrito en la publicación de 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"))
}
La cuenta de demostración permite probar la ruta de inicio de sesión de inmediato; cambia su contraseña antes de usar el servicio para algo que no sea una prueba local. El secreto JWT se comprueba al firmar o analizar tokens, en lugar de recurrir silenciosamente a un valor codificado. Cualquier persona que tenga un JWT firmado puede leerlo, así que no incluyas información confidencial en sus claims.
Añadir el registro TOTP y un desafío de inicio de sesión
El registro solo comienza para un usuario con un token de acceso válido. El servidor devuelve una URL otpauth:// para que el cliente la muestre como código QR. El secreto queda pendiente hasta que un código válido confirma que el autenticador está configurado. Para simplificar, esta demostración lo mantiene en memoria y no lo cifra en reposo.
La ruta de inicio de sesión anterior devuelve una respuesta que requiere TOTP para las cuentas habilitadas, en lugar de emitir un token. La segunda ruta vuelve a comprobar la contraseña junto con el código basado en el tiempo antes de emitirlo. Así, el desafío de este ejemplo queda vinculado a ambos factores, pero no sustituye a una credencial de desafío de un solo uso y corta duración ni a una limitación estricta de intentos por usuario en un sistema desplegado.
// 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"})
})
}
Aquí no hay una ruta para desactivar 2FA: desactivarla debería requerir una nueva autenticación, y una implementación completa debería proporcionar códigos de recuperación para quienes pierdan su autenticador. La verificación TOTP también necesita un límite estricto de intentos. El ejemplo de DEV Community sugiere un límite más estricto para los intentos de 2FA que para el inicio de sesión normal, basado tanto en la IP como en el usuario o la sesión. Un contador en memoria no se compartiría entre varias instancias del servicio.
Conectar el flujo de código de autorización OAuth con PKCE
El archivo final muestra la mecánica de redirección y callback. Cada solicitud de inicio crea un state aleatorio y un verificador PKCE, y luego los guarda brevemente. El callback comprueba el state antes de intercambiar el código de autorización junto con el verificador. El paquete OAuth de Go documenta tanto S256ChallengeOption como VerifierOption; RFC 9700 recomienda PKCE para clientes que usan códigos de autorización e indica que los clientes deben prevenir CSRF.
Este callback no crea ni vincula deliberadamente una cuenta: ese paso requiere validar la respuesta de identidad del proveedor y aplicar una política cuidadosa de vinculación de cuentas. En particular, la publicación de DEV Community advierte que no se deben vincular cuentas automáticamente por correo electrónico, a menos que el proveedor confirme que la dirección está verificada. El código tampoco devuelve tokens del proveedor al navegador.
// 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})
})
}
Ejecuta el servicio con un secreto generado localmente y prueba el inicio de sesión con contraseña. Luego usa el token bearer devuelto para configurar TOTP, escanea el URI recibido con una aplicación de autenticación, habilita 2FA con el código actual y prueba la ruta de inicio de sesión con dos factores.
export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-bytes"
go run .Antes de ir más allá de una demostración
- Almacena las sesiones de forma persistente y revócalas. Añade rotación de refresh tokens y guarda únicamente un hash de cada refresh token. Registra las sesiones para que los usuarios puedan finalizar una o todas. Un JWT de acceso sin estado no se puede revocar antes de tiempo, a menos que las solicitudes también comprueben el estado del lado del servidor.
- Usa límites de solicitudes compartidos. Limita el inicio de sesión, el inicio de OAuth y la verificación de 2FA. Los mapas en memoria del ejemplo no se coordinan entre instancias; la publicación de DEV Community recomienda un almacén compartido como Redis al escalar horizontalmente.
- Protege los secretos y los tokens del navegador. Usa una clave JWT generada de forma segura, protege los secretos TOTP en reposo y evita incluir tokens en las URL de redirección. Si usas cookies, ten en cuenta la protección contra CSRF.
- Completa la gestión de identidad OAuth. Comprueba la respuesta del proveedor y el estado del correo electrónico verificado antes de vincular o crear una cuenta local. Registra URI de redirección exactas; RFC 9700 exige coincidencias exactas, con una excepción limitada para localhost en aplicaciones nativas.
Esta estructura inicial emite tokens de acceso solo después de verificar la contraseña y, si está habilitado, un código TOTP válido. Su callback OAuth comprueba state y PKCE antes de aceptar la respuesta de autorización del proveedor. Las sesiones persistentes, la recuperación y la validación de identidad del proveedor son los siguientes componentes concretos que quedan por implementar.
Fuentes: DEV Community, Editor de RFC / IETF, Paquetes de Go, OWASP