Files
Levyraati26_go/auth.go
T
Esa Kataja 41c8a2914f Add member accounts: invites, registration, login, sessions, ban
Step 2 of the build order. The admin mints an invite link, the recipient
registers with it, and from then on has a session.

- The invite is spent in the same transaction that creates the account, so a
  failed signup leaves the code usable
- Sessions are idle timeouts, 24h or 30 days with remember me, read from a
  cookie or a bearer header, extended at most once a minute
- Ban is a reversible toggle that drops the member's live sessions
- No password minimum; login is rate limited instead, 10 failures per email
  in 15 minutes, cleared by a correct password
- Invite codes render as links carrying ?code=, which the register form
  prefills; PUBLIC_URL makes them pasteable from the loopback admin panel

Tests cover invite spending, the idle timeout, ban, and the rate limiter.
2026-07-31 20:57:13 +03:00

320 lines
9.8 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "session"
idleShort = 24 * time.Hour
idleRemember = 30 * 24 * time.Hour
// Skip the extending UPDATE unless the session has aged at least this much, so a sliding
// session is not a write on every request.
extendAfter = time.Minute
)
type member struct {
ID int64
Name string
Email string
Avatar *string
Banned bool
CreatedAt time.Time
}
// Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string {
out := ""
for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0]))
if len(out) == 2 {
break
}
}
return out
}
type ctxKey int
const memberKey ctxKey = 0
func memberFrom(ctx context.Context) *member {
m, _ := ctx.Value(memberKey).(*member)
return m
}
func token() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// A bearer header as well as the cookie, so something that isn't a browser can authenticate
// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page
// cannot set Authorization without CORS, which is not enabled.
func sessionToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if c, err := r.Cookie(sessionCookie); err == nil {
return c.Value
}
return ""
}
func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) {
ttl := idleShort
if remember {
ttl = idleRemember
}
tok := token()
expires := time.Now().Add(ttl)
_, err := a.pool.Exec(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, ttl, expires)
return tok, expires, err
}
func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", Expires: expires,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
// session loads the member behind a token, extends the idle timeout, and treats a banned or
// expired session as no session at all.
func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
tok := sessionToken(r)
if tok == "" {
return nil
}
var (
m member
expires time.Time
ttl time.Duration
ttlMicros int64
)
err := a.pool.QueryRow(r.Context(), `
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > now()`, tok).
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err)
}
return nil
}
if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil
}
ttl = time.Duration(ttlMicros) * time.Microsecond
if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl)
if _, err := a.pool.Exec(r.Context(),
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
a.setSessionCookie(w, tok, newExpiry)
}
}
return &m
}
func (a *app) withMember(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m := a.session(w, r); m != nil {
r = r.WithContext(context.WithValue(r.Context(), memberKey, m))
}
next.ServeHTTP(w, r)
})
}
func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if memberFrom(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// --- pages ---
type authForm struct {
Name, Email, Code string
Errors map[string]string
}
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}})
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
form := authForm{Email: email, Errors: map[string]string{}}
if a.logins.locked(email) {
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form})
return
}
var (
id int64
hash string
banned bool
)
err := a.pool.QueryRow(r.Context(),
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email)
// One message for both cases: a distinct "no such account" tells anyone who asks which
// addresses are members.
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form})
return
}
if banned {
form.Errors["form"] = "Tunnus on estetty."
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form})
return
}
tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "")
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.logins.succeed(email)
a.setSessionCookie(w, tok, expires)
slog.Info("login", "ctx", "auth", "user", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "register.html",
page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}})
}
// register spends the invite only when the account is actually created: both statements are in one
// transaction, so a failed signup leaves the code usable.
func (a *app) register(w http.ResponseWriter, r *http.Request) {
form := authForm{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))),
Code: strings.TrimSpace(r.FormValue("code")),
Errors: map[string]string{},
}
password := r.FormValue("password")
if form.Name == "" || len([]rune(form.Name)) > 50 {
form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä."
}
if !strings.Contains(form.Email, "@") {
form.Errors["email"] = "Tarkista sähköpostiosoite."
}
// ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset
// path — a minimum buys nothing here and makes dev accounts tedious.
if password == "" {
form.Errors["password"] = "Salasana on pakollinen."
}
if form.Code == "" {
form.Errors["code"] = "Kutsukoodi on pakollinen."
}
if len(form.Errors) > 0 {
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
slog.Error("hash password", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
tx, err := a.pool.Begin(r.Context())
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var inviteID int64
err = tx.QueryRow(r.Context(),
`update invites set is_valid = false where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
return
} else if err != nil {
slog.Error("burn invite", "ctx", "invites", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
var userID int64
err = tx.QueryRow(r.Context(),
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) {
// Rolls back, so the invite is still valid.
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
return
} else if err != nil {
slog.Error("create user", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID)
tok, expires, err := a.startSession(r.Context(), userID, false)
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.setSessionCookie(w, tok, expires)
a.flash(w, "Tervetuloa mukaan!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func isUnique(err error) bool {
var pgErr interface{ SQLState() string }
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
}