
Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker
Código completo: todos los archivos de la serie están en el gist vue-go-echo-v5, con un README que muestra en qué carpeta se encuentra cada uno.
La API de tareas de parte 3 está abierta a cualquiera. En esta parte gana login: contraseña guardada como hash bcrypt, sesión en una cookie HttpOnly, un middleware de Echo v5 que bloquea las rutas privadas y, en Vue, la pantalla de login con protección de rutas en Vue Router. Es un diseño sencillo, pero con las decisiones de seguridad correctas para una aplicación pequeña.
Muchos tutoriales guardan un JWT en localStorage y lo envían en cada petición. Funciona, pero cualquier JavaScript de la página puede leer ese token, y un XSS se convierte en un robo de sesión. Como aquí el frontend y la API están en el mismo origen (proxy de Vite en desarrollo, binario único en producción), se puede usar el mecanismo más antiguo y más seguro:
- Go genera un token aleatorio y lo guarda en una cookie
HttpOnly, que JavaScript no puede leer; - el navegador envía la cookie por sí solo en cada
fetch; SameSite=Strictimpide que otro sitio envíe peticiones autenticadas en nombre del usuario;- el cierre de sesión elimina la sesión en el servidor: el token deja de ser válido al instante, lo que un JWT sin lista de revocación no hace.
La aplicación tiene un único usuario, definido por variables de entorno. Con varios usuarios, lo único que cambia es de dónde viene el hash de la contraseña: de una tabla en la base de datos.
El backend de autenticación
Instala el paquete de bcrypt:
go get golang.org/x/crypto/bcrypt
Cree internal/api/auth.go. Primero la estructura y el constructor, que guarda solo el hash de la contraseña, nunca el texto:
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net/http"
"sync"
"time"
"github.com/labstack/echo/v5"
"golang.org/x/crypto/bcrypt"
)
const (
cookieName = "sessao"
sessionTTL = 8 * time.Hour
)
// Auth guarda o usuário único da aplicação e as sessões abertas.
type Auth struct {
user string
passHash []byte
mu sync.Mutex
sessions map[string]session
}
type session struct {
user string
expires time.Time
}
// NewAuth recebe o usuário e a senha em texto e guarda só o hash bcrypt.
func NewAuth(user, password string) (*Auth, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
return &Auth{user: user, passHash: hash, sessions: map[string]session{}}, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
El token de sesión tiene 32 bytes de crypto/rand: impredecible, al contrario que un contador o de math/rand.
Login
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login confere usuário e senha e cria a sessão em um cookie HttpOnly.
func (a *Auth) Login(c *echo.Context) error {
var req loginRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
}
userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(a.user)) == 1
passOK := bcrypt.CompareHashAndPassword(a.passHash, []byte(req.Password)) == nil
if !userOK || !passOK {
return echo.NewHTTPError(http.StatusUnauthorized, "usuário ou senha inválidos")
}
token, err := newToken()
if err != nil {
return err
}
a.mu.Lock()
a.sessions[token] = session{user: a.user, expires: time.Now().Add(sessionTTL)}
a.mu.Unlock()
c.SetCookie(&http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: c.Request().TLS != nil || c.Request().Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteStrictMode,
})
return c.JSON(http.StatusOK, map[string]string{"username": a.user})
}
Puntos que merecen atención:
- bcrypt se ejecuta incluso cuando el usuario es incorrecto, y la comparación del nombre se hace en tiempo constante. Así, el tiempo de respuesta no revela si el usuario existe;
- el mensaje de error es el mismo tanto para usuario como para contraseña incorrectos;
Secureconecta cuando la conexión es HTTPS, directa o detrás de un proxy que envíaX-Forwarded-Proto: https(parte 5). Enhttp://localhostse queda desactivado, si no el navegador descartaría la cookie.
Logout, usuario actual y el middleware
// Logout apaga a sessão no servidor e o cookie no navegador.
func (a *Auth) Logout(c *echo.Context) error {
if ck, err := c.Cookie(cookieName); err == nil {
a.mu.Lock()
delete(a.sessions, ck.Value)
a.mu.Unlock()
}
c.SetCookie(&http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return c.NoContent(http.StatusNoContent)
}
// Me devolve o usuário da sessão atual.
func (a *Auth) Me(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]any{"username": c.Get("user")})
}
// Required é o middleware que bloqueia as rotas sem sessão válida.
func (a *Auth) Required(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ck, err := c.Cookie(cookieName)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "faça login")
}
a.mu.Lock()
s, ok := a.sessions[ck.Value]
if ok && time.Now().After(s.expires) {
delete(a.sessions, ck.Value)
ok = false
}
a.mu.Unlock()
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "sessão expirada")
}
c.Set("user", s.user)
return next(c)
}
}
Un middleware en Echo es una función que recibe el siguiente handler y devuelve otro. El Required bloquea la petición con 401 o, si la sesión es válida, guarda al usuario en el contexto con c.Set y sigue al handler, que lee el valor con c.Get.
Protege las rutas
Actualiza internal/api/routes.go. Las rutas privadas reciben auth.Required como último argumento, el middleware a nivel de ruta:
// Register liga as rotas em /api.
func Register(e *echo.Echo, auth *Auth, tasks *Tasks) {
g := e.Group("/api")
// Rotas públicas
g.GET("/health", func(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
g.POST("/login", auth.Login)
g.POST("/logout", auth.Logout)
// Rotas que exigem sessão: o middleware vai como último argumento
g.GET("/me", auth.Me, auth.Required)
g.GET("/tasks", tasks.List, auth.Required)
g.POST("/tasks", tasks.Create, auth.Required)
g.PATCH("/tasks/:id", tasks.Update, auth.Required)
g.DELETE("/tasks/:id", tasks.Delete, auth.Required)
}
¿Por qué no un subgrupo g.Group("", auth.Required)? En nuestra prueba, el subgrupo con prefijo vacío y middleware también capturaba rutas inexistentes de /api: una URL errónea respondía 401 en vez de 404. Con el middleware por ruta, la ruta inexistente sigue dando 404 y la protegida da 401, y la lectura queda explícita.
En el main.go, la contraseña viene de la variable APP_PASSWORD, sin valor por defecto, y el servidor rechaza arrancar sin ella:
func main() {
user := getenv("APP_USER", "admin")
pass := os.Getenv("APP_PASSWORD")
if pass == "" {
slog.Error("defina APP_PASSWORD")
os.Exit(1)
}
auth, err := api.NewAuth(user, pass)
if err != nil {
slog.Error("hash da senha", "error", err)
os.Exit(1)
}
e := echo.New()
e.Use(middleware.RequestLogger())
e.Use(middleware.Recover())
api.Register(e, auth, api.NewTasks())
if err := e.Start(getenv("APP_ADDR", "127.0.0.1:8080")); err != nil {
slog.Error("servidor", "error", err)
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Añade "os" a los imports. Prueba el flujo completo con curl, guardando la cookie en un archivo:
APP_PASSWORD=segredo123 go run .
B=http://127.0.0.1:8080; J='Content-Type: application/json'
curl -s -o /dev/null -w 'tasks sem login: %{http_code}\n' $B/api/tasks
curl -s -H "$J" -d '{"username":"admin","password":"errada"}' $B/api/login; echo
curl -s -c ck.txt -H "$J" -d '{"username":"admin","password":"segredo123"}' $B/api/login; echo
curl -s -b ck.txt $B/api/me; echo
curl -s -b ck.txt -X POST -o /dev/null -w 'logout: %{http_code}\n' $B/api/logout
curl -s -b ck.txt -o /dev/null -w 'me após logout: %{http_code}\n' $B/api/me
tasks sem login: 401
{"message":"usuário ou senha inválidos"}
{"username":"admin"}
{"username":"admin"}
logout: 204
me após logout: 401
La última línea muestra la ventaja de la sesión en el servidor: la misma cookie, reenviada después del logout, ya no vale nada.
La pantalla de inicio de sesión en Vue
Sustituye web/src/views/LoginView.vue:
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
const router = useRouter()
const route = useRoute()
const username = ref('admin')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function login() {
error.value = ''
loading.value = true
try {
await api('POST', '/login', { username: username.value, password: password.value })
router.push(route.query.next || '/tarefas')
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
<template>
<form class="card" @submit.prevent="login">
<h1>Entrar</h1>
<label>Usuário <input v-model="username" autocomplete="username" required /></label>
<label>Senha <input v-model="password" type="password" autocomplete="current-password" required /></label>
<p v-if="error" class="error">{{ error }}</p>
<button :disabled="loading">{{ loading ? 'Entrando...' : 'Entrar' }}</button>
</form>
</template>
Ningún token pasa por JavaScript: el inicio de sesión solo necesita saber si fue correcto. Quien guarda la sesión es el navegador.

Protección de rutas en Vue Router
La guarda beforeEach que escribimos en la parte 2 ahora funciona: al abrir una ruta con meta.auth, llama a /api/me; si recibe 401, envía a /login?next=/tarefas, y después del inicio de sesión el usuario vuelve adonde quería ir. Restaura el meta: { auth: true } y la línea de /me no TasksView, si las quitaste para probar.
Recuerda que esto es una comodidad de interfaz. Quien protege los datos es Go: aunque alguien salte la protección de Vue, cada ruta de la API sigue exigiendo la sesión.
Con go run . e npm run dev en ejecución, abre http://localhost:5173/tarefas. En nuestra prueba con navegador automatizado, el flujo completo pasó:
- redirigió al inicio de sesión;
- rechazó la contraseña incorrecta;
- entró;
- creó, marcó y eliminó tareas;
- mantuvo la sesión al recargar la página;
- tras cerrar sesión, volvió a pedir inicio de sesión.
Qué falta para producción
- Sesiones en memoria desaparecen al reiniciar y no funcionan con varias instancias. Para eso, guárdalas en una base de datos o en Redis;
- límite de intentos: Echo dispone del middleware
RateLimiter; aplícalo a la ruta de inicio de sesión; - HTTPS es obligatorio fuera de
localhost, si no, la cookie viaja en texto plano. En la parte 5 ponemos Nginx delante.
Archivos de esta parte en el gist
Cada archivo abre directamente en el gist de la serie. El gist trae la versión final del proyecto: main.go e routes.go también obtienen login en la parte 4 y el frontend integrado en la parte 5.
auth.go(eninternal/api/auth.go)routes.go(eninternal/api/routes.go)main.goLoginView.vue(enweb/src/views/LoginView.vue)
Próximo paso
La aplicación está completa, pero aún se ejecuta como dos procesos. En la parte 5 embebimos Vue en el binario Go con go:embed, generamos un único ejecutable, compilamos todo con Docker y publicamos con systemd y Nginx.
Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker