Files
Levyraati26_go/auth.go
T
Esa Kataja 2af29fe999 Make the admin a member with a flag, and drop the second listener
The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
2026-09-05 13:40:26 +03:00

331 lines
10 KiB
Go

package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
)
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
IsAdmin bool
CreatedAt time.Time
}
// Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string {
out, n := "", 0
for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0]))
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
if n++; n == 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.db.ExecContext(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, int64(ttl.Seconds()), 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
ttlSeconds int64
)
err := a.db.QueryRowContext(r.Context(), `
select s.expires_at, s.idle_ttl,
u.id, u.name, u.email, u.avatar, u.banned, u.is_admin, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > datetime('now')`, tok).
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.IsAdmin, &m.CreatedAt)
if err != nil {
if !errors.Is(err, sql.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.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil
}
ttl = time.Duration(ttlSeconds) * time.Second
if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl)
if _, err := a.db.ExecContext(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", Narrow: true, 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", Narrow: true, Data: form})
return
}
var (
id int64
hash string
banned bool
)
err := a.db.QueryRowContext(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", Narrow: true, Data: form})
return
}
if banned {
form.Errors["form"] = "Tunnus on estetty."
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, 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.db.ExecContext(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", Narrow: true, 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", Narrow: true, 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.db.BeginTx(r.Context(), nil)
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback()
var inviteID int64
err = tx.QueryRowContext(r.Context(),
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, sql.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, 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.QueryRowContext(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", Narrow: true, 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(); 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)
}
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
const (
sqliteConstraintUnique = 2067
sqliteConstraintPrimaryKey = 1555
)
func isUnique(err error) bool {
var e *sqlite.Error
return errors.As(err, &e) &&
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
}