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