
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.
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=Strictimpede 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;
Secureliga quando a conexão é HTTPS, direta ou atrás de um proxy que enviaX-Forwarded-Proto: https(parte 5). Emhttp://localhostele 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.

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.
auth.go(eminternal/api/auth.go)routes.go(eminternal/api/routes.go)main.goLoginView.vue(emweb/src/views/LoginView.vue)
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