Vue.js + Go com Echo v5, parte 4: login com sessão e cookie

Mascote LinuxPro abrindo uma porta com cartão de acesso e segurando um cookie, com tela de login do Vue e o cachorro caramelo cyborg sentado ao lado

Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker

Código completo: todos os arquivos da série estão no gist vue-go-echo-v5, com um README que mostra em qual pasta cada um fica.

A API de tarefas da parte 3 está aberta para qualquer um. Nesta parte ela ganha login: senha guardada como hash bcrypt, sessão num cookie HttpOnly, um middleware do Echo v5 que bloqueia as rotas privadas e, no Vue, a tela de login com proteção de rotas no Vue Router. É um desenho simples, mas com as escolhas certas de segurança para uma aplicação pequena.

Por que sessão em cookie, e não JWT no localStorage

Muitos tutoriais guardam um JWT no localStorage e o enviam em cada requisição. Funciona, mas qualquer JavaScript da página consegue ler esse token, e um XSS vira roubo de sessão. Como aqui o frontend e a API estão na mesma origem (proxy do Vite em desenvolvimento, binário único em produção), dá para usar o mecanismo mais antigo e mais seguro:

  • o Go gera um token aleatório e o grava num cookie HttpOnly, que o JavaScript não consegue ler;
  • o navegador envia o cookie sozinho a cada fetch;
  • SameSite=Strict impede que outro site dispare requisições autenticadas em nome do usuário;
  • o logout apaga a sessão no servidor: o token deixa de valer na hora, o que um JWT sem lista de revogação não faz.

A aplicação tem um único usuário, definido por variáveis de ambiente. Com vários usuários, o que muda é só de onde vem o hash da senha: de uma tabela no banco.

O backend de autenticação

Instale o pacote de bcrypt:

go get golang.org/x/crypto/bcrypt

Crie internal/api/auth.go. Primeiro a estrutura e o construtor, que guarda só o hash da senha, nunca o 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
}

O token de sessão tem 32 bytes de crypto/rand: imprevisível, ao contrário de um contador ou 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})
}

Pontos que valem a atenção:

  • o bcrypt roda mesmo quando o usuário está errado, e a comparação do nome é em tempo constante. Assim o tempo de resposta não revela se o usuário existe;
  • a mensagem de erro é a mesma para usuário e senha errados;
  • Secure liga quando a conexão é HTTPS, direta ou atrás de um proxy que envia X-Forwarded-Proto: https (parte 5). Em http://localhost ele fica desligado, senão o navegador descartaria o cookie.

Logout, usuário atual e o 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)
	}
}

Um middleware no Echo é uma função que recebe o próximo handler e devolve outro. O Required barra a requisição com 401 ou, se a sessão for válida, guarda o usuário no contexto com c.Set e segue para o handler, que lê o valor com c.Get.

Proteja as rotas

Atualize internal/api/routes.go. As rotas privadas recebem auth.Required como último argumento, o middleware no nível da rota:

// 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 que não um subgrupo g.Group("", auth.Required)? No nosso teste, o subgrupo com prefixo vazio e middleware também capturava rotas inexistentes de /api: uma URL errada respondia 401 em vez de 404. Com o middleware por rota, a rota inexistente continua dando 404 e a protegida dá 401, e a leitura fica explícita.

No main.go, a senha vem da variável APP_PASSWORD, sem valor padrão, e o servidor recusa subir sem ela:

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
}

Acrescente "os" aos imports. Teste o fluxo completo com curl, guardando o cookie em arquivo:

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

A última linha mostra a vantagem da sessão no servidor: o mesmo cookie, reenviado depois do logout, já não vale nada.

A tela de login no Vue

Substitua 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>

Nenhum token passa pelo JavaScript: o login só precisa saber se deu certo. Quem guarda a sessão é o navegador.

Tela de login em Vue exibindo o erro de usuário ou senha inválidos devolvido pela API Go

Proteção de rotas no Vue Router

A guarda beforeEach que escrevemos na parte 2 agora funciona: ao abrir uma rota com meta.auth, ela chama /api/me; se receber 401, manda para /login?next=/tarefas, e depois do login o usuário volta para onde queria ir. Restaure o meta: { auth: true } e a linha do /me no TasksView, se você as tirou para testar.

Lembre que isso é conveniência de interface. Quem protege os dados é o Go: mesmo que alguém pule a guarda do Vue, cada rota da API continua exigindo a sessão.

Com go run . e npm run dev rodando, abra http://localhost:5173/tarefas. No nosso teste com navegador automatizado, o fluxo inteiro passou:

  • redirecionou para o login;
  • recusou a senha errada;
  • entrou;
  • criou, marcou e removeu tarefas;
  • manteve a sessão ao recarregar a página;
  • depois do logout, voltou a exigir login.

O que falta para produção

  • Sessões em memória somem ao reiniciar e não funcionam com várias instâncias. Para isso, guarde-as num banco ou no Redis;
  • limite de tentativas: o Echo tem o middleware RateLimiter; aplique-o à rota de login;
  • HTTPS é obrigatório fora do localhost, senão o cookie trafega em texto claro. Na parte 5 colocamos o Nginx na frente.

Arquivos desta parte no gist

Cada arquivo abre direto no gist da série. O gist traz a versão final do projeto: main.go e routes.go ainda ganham login na parte 4 e o frontend embutido na parte 5.

Próximo passo

A aplicação está completa, mas ainda roda como dois processos. Na parte 5 embutimos o Vue no binário Go com go:embed, geramos um executável único, compilamos tudo com Docker e publicamos com systemd e Nginx.

Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker