Receitas
Sessões
Login com cookie assinado, o usuário atual num middleware, uma mensagem flash que sobrevive a um redirecionamento — e nada guardado no servidor.
ctx --pack · est. 5048 tokens est. · llms.txt da receita
Uma sessão são duas decisões: o que prova quem você é, e onde essa prova fica. Trilha responde a primeira — um cookie assinado com o segredo do app, que o navegador não consegue forjar — e deixa a segunda com você. Esta receita não guarda nada no servidor: o cookie carrega o id do usuário, e toda requisição lê o usuário do banco.
Isso custa uma consulta indexada por requisição e compra algo que vale mais: desativar uma conta passa a valer agora, não quando o cookie expirar.
Ou: trilha add login#
Tudo abaixo — o cookie, o middleware do usuário atual, o flash — é também o que a receita login escreve para você:
trilha add login --dry-run + internal/usuarios/usuarios.go
+ internal/usuarios/usuarios_test.go
+ internal/sessao/sessao.go
+ app/entrar/page.go
+ app/sair/route.go
+ app/sair/middleware.go
+ app/error.go
+ internal/sessao/sessaotest/sessaotest.go
+ login_test.go
~ app/setup.go (uma linha acrescentada)
--dry-run: nada foi escritoA saída confere o token CSRF (sair/middleware.go, porque um route.go é API e não conferiria), e o app/error.go — escrito só quando o projeto não tem um — põe um 401 ou 403 no layout do app em vez da página nua do framework.
Isso é uma tabela de usuários, uma tela de entrar e de sair, e a ligação em app/setup.go — um login que funciona antes de você escrever uma linha, e o que toda outra receita do trilha add espera existir primeiro. O que ela não entrega é o raciocínio abaixo: por que um erro só serve tanto para "e-mail não existe" quanto para "senha errada", por que existe o dummyHash, por que o ?next= precisa do safeNext. Rode a receita pela tela; leia esta página para saber qual linha mudar, e por que mudá-la continua seguro.
Entrando#
// Login answers the form. The session is written before the redirect,
// because a Set-Cookie on a 303 still reaches the browser.
func Login(c *trilha.Ctx) error {
u, err := Authenticate(c.Context(), c.Form("email"), c.Form("password"))
if err != nil {
return trilha.FieldErrors{"email": "wrong e-mail or password"}
}
if err := c.SetSigned(SessionCookie, strconv.FormatInt(u.ID, 10), SessionTTL); err != nil {
return err
}
return c.Redirect(safeNext(c.Form("next")))
}SetSigned escreve o cookie com HttpOnly, SameSite=Lax e Secure fora de dev, e assina o valor com o Secret do app. O valor não é criptografado e não precisa ser: ele é o id do próprio visitante.
// SessionCookie carries the user id, signed by the app's secret. What is
// inside it is not secret — it is the id, and anyone may read their own —
// but it cannot be changed without the key.
const SessionCookie = "session"A conferência da senha é a única coisa que o framework não vai fazer por você — e a biblioteca padrão também não:
// CheckPassword compares a password with the stored hash. The standard
// library has no password hash worth using, so this is where your app plugs
// in bcrypt or argon2; the default refuses everyone, which is the safe way
// to notice it was never wired.
var CheckPassword = func(hash, password string) bool { return false }
No seu app, essa variável aponta para bcrypt.CompareHashAndPassword ou argon2.IDKey. Aqui ela recusa todo mundo, para que esquecer de ligá-la falhe fechado.
// Authenticate reads the user and checks the password. One error for "no
// such e-mail" and for "wrong password": telling them apart hands an
// attacker a list of who has an account.
func Authenticate(ctx context.Context, email, password string) (User, error) {
var u User
err := DB.QueryRowContext(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).
Scan(&u.ID, &u.Email, &u.Hash)
if err != nil {
u.Hash = dummyHash
}
if !CheckPassword(u.Hash, password) || err != nil {
return User{}, ErrBadCredentials
}
return u, nil
}Dois detalhes pagam as suas linhas. O erro único faz com que a página de login não sirva para descobrir quais e-mails têm conta. O dummyHash faz com que ela também não sirva por tempo:
// dummyHash keeps the comparison cost the same for an e-mail that does not
// exist: without it, the time the answer takes says which e-mails are real.
const dummyHash = "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"E o redirecionamento depois do login passa por uma checagem, porque ?next= é o open redirect clássico — uma página de login que manda a pessoa para outro site depois de ela digitar a senha:
// safeNext refuses a destination that leaves the site: ?next= is how an
// open redirect gets into a login page.
func safeNext(next string) string {
u, err := url.Parse(next)
if err != nil || u.Scheme != "" || u.Host != "" || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") {
return "/"
}
return u.Path
}O usuário atual#
O middleware roda para toda rota da pasta em que ele mora e abaixo dela, então app/middleware.go cobre o app inteiro:
// WithUser reads the session and puts the user in the request. It refuses
// nobody: a page that requires a login says so itself, and a page that only
// greets by name works either way.
func WithUser(c *trilha.Ctx, next trilha.Next) error {
if id, ok := c.Signed(SessionCookie); ok {
if u, err := UserByID(c.Context(), id); err == nil {
c.Set(UserKey, u)
}
}
return next()
}Ele não recusa ninguém, de propósito. Uma página que exige login diz isso por conta própria, e uma página que só cumprimenta pelo nome funciona dos dois jeitos:
// RequireUser sends anyone the middleware did not recognise to the login
// page, remembering where they were going.
func RequireUser(c *trilha.Ctx, next trilha.Next) error {
if _, ok := c.Get(UserKey).(User); !ok {
return c.Redirect("/login?next=" + url.QueryEscape(c.Request().URL.Path))
}
return next()
}// CurrentUser is what a handler calls. The zero User means nobody is
// logged in, so a page can ask without checking twice.
func CurrentUser(c *trilha.Ctx) User {
u, _ := c.Get(UserKey).(User)
return u
}O User zerado quer dizer "ninguém", então uma página pode perguntar sem conferir duas vezes. Ler o usuário é uma consulta, e é a consulta que dá dentes à sessão:
// UserByID reads the user the session points at, on every request. That is
// one indexed query for the ability to disable an account and have it take
// effect now, instead of when the cookie expires.
func UserByID(ctx context.Context, id string) (User, error) {
var u User
err := DB.QueryRowContext(ctx, `SELECT id, email, password_hash FROM users WHERE id = $1 AND active`, id).
Scan(&u.ID, &u.Email, &u.Hash)
return u, err
}Saindo#
// Logout clears the cookie. Nothing is stored on the server, so there is
// nothing else to forget.
func Logout(c *trilha.Ctx) error {
c.ClearCookie(SessionCookie)
return c.Redirect("/")
}Não há mais nada para esquecer, o que é a vantagem de uma sessão sem estado — e o limite dela: um cookie roubado vale até expirar. Se você precisa revogar um, precisa do store.
Flash#
A mensagem que tem que sobreviver a um redirecionamento e depois sumir:
// Flash writes the message the next page will show.
func Flash(c *trilha.Ctx, msg string) error {
return c.SetSigned(FlashCookie, msg, 5*time.Minute)
}// TakeFlash reads the message and clears it, so a reload does not show it
// again.
func TakeFlash(c *trilha.Ctx) string {
msg, ok := c.Signed(FlashCookie)
if !ok {
return ""
}
c.ClearCookie(FlashCookie)
return msg
}Assinada, para que ninguém coloque um texto próprio na sua página editando um cookie. Lida uma vez, para que um recarregamento não a mostre de novo.