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.
This commit is contained in:
Esa Kataja
2026-07-31 20:57:13 +03:00
parent 0a8c36fd82
commit 41c8a2914f
18 changed files with 1274 additions and 8 deletions
+4
View File
@@ -5,3 +5,7 @@ ADMIN_PASSWORD=
# Set to false only for local development over plain HTTP.
SECURE_COOKIES=true
# Public address of the member site. Used to build pasteable invite links in the admin panel.
# Unset falls back to a relative link, which is fine locally.
PUBLIC_URL=https://levyraati.example.com
+1
View File
@@ -57,6 +57,7 @@ creates no users: log into the admin panel and mint an invite.
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
### Local development
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
)
type adminInvite struct {
ID int64
Code string
IsValid bool
CreatedAt time.Time
Link string
}
type adminMember struct {
ID int64
Name string
Email string
Banned bool
CreatedAt time.Time
}
type dashboard struct {
Invites []adminInvite
Members []adminMember
}
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
var d dashboard
rows, err := a.pool.Query(r.Context(),
`select id, code, is_valid, created_at from invites order by created_at desc limit 50`)
if err != nil {
adminError(w, "invites", err)
return
}
for rows.Next() {
var i adminInvite
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
adminError(w, "invites", err)
return
}
i.Link = a.inviteLink(i.Code)
d.Invites = append(d.Invites, i)
}
rows.Close()
if err := rows.Err(); err != nil {
adminError(w, "invites", err)
return
}
rows, err = a.pool.Query(r.Context(),
`select id, name, email, banned, created_at from users order by created_at`)
if err != nil {
adminError(w, "users", err)
return
}
defer rows.Close()
for rows.Next() {
var m adminMember
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
adminError(w, "users", err)
return
}
d.Members = append(d.Members, m)
}
if err := rows.Err(); err != nil {
adminError(w, "users", err)
return
}
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
}
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
func inviteCode() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
// which is enough locally.
func (a *app) inviteLink(code string) string {
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
}
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
code := inviteCode()
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
adminError(w, "invites", err)
return
}
slog.Info("invite minted", "ctx", "invites")
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
// doesn't repeat the URL as unclickable text.
a.flash(w, "Uusi kutsulinkki luotu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
// would leave a banned member browsing until their session expired.
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
var banned bool
err = a.pool.QueryRow(r.Context(),
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
if err != nil {
adminError(w, "users", err)
return
}
if banned {
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "users", err)
return
}
a.flash(w, "Jäsen estetty.")
} else {
a.flash(w, "Esto poistettu.")
}
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
password := r.FormValue("password")
if password == "" {
a.flash(w, "Salasana on pakollinen.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
adminError(w, "auth", err)
return
}
if _, err := a.pool.Exec(r.Context(),
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
adminError(w, "auth", err)
return
}
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "auth", err)
return
}
slog.Info("password reset by admin", "ctx", "auth", "user", id)
a.flash(w, "Salasana vaihdettu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func adminError(w http.ResponseWriter, ctx string, err error) {
slog.Error("admin", "ctx", ctx, "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
}
+319
View File
@@ -0,0 +1,319 @@
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"
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
func testApp(t *testing.T) *app {
t.Helper()
dbURL := os.Getenv("TEST_DATABASE_URL")
if dbURL == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err)
}
if err := migrate(ctx, pool); err != nil {
t.Fatal(err)
}
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
}
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func (a *app) inviteValid(t *testing.T, code string) bool {
t.Helper()
var valid bool
if err := a.pool.QueryRow(context.Background(),
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
t.Fatal(err)
}
return valid
}
// A failed registration must leave the code usable; a successful one must not.
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
t.Fatal(err)
}
if _, err := a.pool.Exec(ctx,
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
t.Fatal(err)
}
// Taken email — the insert fails after the invite has already been marked spent in the tx.
w := post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"},
"email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
}
if !a.inviteValid(t, "kutsu1") {
t.Fatal("failed registration spent the invite")
}
// Missing password — rejected before the invite is touched at all.
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("short password: status = %d, want 422", w.Code)
}
if !a.inviteValid(t, "kutsu1") {
t.Fatal("rejected registration spent the invite")
}
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("valid registration: status = %d, want 303", w.Code)
}
if a.inviteValid(t, "kutsu1") {
t.Fatal("successful registration left the invite usable")
}
// And it cannot be used twice.
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("reused invite: status = %d, want 422", w.Code)
}
}
// The limiter has its own unit test; this covers the wiring into the handler.
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
a.seedMember(t, "[email protected]")
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
for i := range loginMaxFailures {
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
}
}
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
}
}
func (a *app) seedMember(t *testing.T, email string) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(),
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
email).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func (a *app) sessionFor(t *testing.T, token string) *member {
t.Helper()
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
return a.session(httptest.NewRecorder(), r)
}
func TestSessionIdleTimeout(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
live, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, live); m == nil || m.ID != id {
t.Fatal("fresh session did not resolve to its member")
}
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, live); m != nil {
t.Fatal("expired session still resolved")
}
// A session used inside the window slides forward.
fresh, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, fresh); m == nil {
t.Fatal("session inside the window did not resolve")
}
var expires time.Time
if err := a.pool.QueryRow(ctx,
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
t.Fatal(err)
}
if time.Until(expires) < 23*time.Hour {
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
}
}
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
t.Fatal(err)
}
w := post(t, mux, "/register", url.Values{
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("registration: status = %d, want 303", w.Code)
}
var id int64
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
t.Fatal(err)
}
adminMux := a.adminMux()
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("ban: status = %d, want 303", w.Code)
}
var sessions int
if err := a.pool.QueryRow(ctx,
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Fatalf("banned member kept %d sessions", sessions)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
if w.Code != http.StatusForbidden {
t.Fatalf("banned login: status = %d, want 403", w.Code)
}
// Reversible: unban, and the same credentials work again.
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("unban: status = %d, want 303", w.Code)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("login after unban: status = %d, want 303", w.Code)
}
}
+3 -2
View File
@@ -27,11 +27,12 @@ services:
# published below, so it stays unreachable from outside without a tunnel or the proxy.
ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true}
PUBLIC_URL: ${PUBLIC_URL:-}
volumes:
- ./storage:/storage
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8081:8081"
- "8080:8080"
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
+9
View File
@@ -158,3 +158,12 @@ says so.
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
`dev`.
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
password on every dev account is friction with nothing behind it — there is no public
registration to spray, and the admin is the reset path. It was also deliberately *not* made
configurable: settings like this belong in code, not in an env file that grows a line per
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
else out by trying.
+6 -1
View File
@@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en
### 1.2 Security behaviours
- Changing your own password requires the current password.
- Passwords are bcrypt.
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
ten members, and an admin-only reset path leave a length policy nothing to protect.
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
+26 -3
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,6 +21,9 @@ type config struct {
adminAddr string
storageDir string
secureCookies bool
// Public address of the member site, so admin-side invite links are pasteable. The admin
// listener's own Host is a tunnel, not the site, so it cannot be derived.
publicURL string
}
func loadConfig() config {
@@ -31,6 +35,7 @@ func loadConfig() config {
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
}
if c.databaseURL == "" {
fatal("DATABASE_URL is not set")
@@ -57,6 +62,7 @@ func fatal(msg string, args ...any) {
type app struct {
cfg config
pool *pgxpool.Pool
logins limiter // zero value is ready to use
}
func main() {
@@ -108,11 +114,13 @@ func main() {
}()
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.memberMux()))
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
}
func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.pool.Ping(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
@@ -120,13 +128,28 @@ func (a *app) memberMux() *http.ServeMux {
}
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("GET /register", a.registerPage)
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(func(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"})
}))
return mux
}
func (a *app) adminMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("levyraati admin"))
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /admin", a.adminDashboard)
mux.HandleFunc("POST /admin/invites", a.createInvite)
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
})
return mux
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"sync"
"time"
)
// Login attempts are limited per email address, which is the thing under attack — and the only key
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
//
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
const (
loginMaxFailures = 10
loginWindow = 15 * time.Minute
loginLockout = 15 * time.Minute
)
type attempts struct {
count int
first time.Time
until time.Time // zero unless locked
}
type limiter struct {
mu sync.Mutex
by map[string]*attempts
}
// locked reports whether the key is currently refused. It does not count as an attempt.
func (l *limiter) locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
a := l.by[key]
return a != nil && time.Now().Before(a.until)
}
func (l *limiter) fail(key string) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.by == nil {
l.by = map[string]*attempts{}
}
l.sweep(now)
a := l.by[key]
if a == nil || now.Sub(a.first) > loginWindow {
l.by[key] = &attempts{count: 1, first: now}
return
}
a.count++
if a.count >= loginMaxFailures {
a.until = now.Add(loginLockout)
}
}
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
// account itself would let anyone lock its owner out by trying.
func (l *limiter) succeed(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.by, key)
}
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
func (l *limiter) sweep(now time.Time) {
for k, a := range l.by {
if now.Sub(a.first) > loginWindow && now.After(a.until) {
delete(l.by, k)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
"time"
)
func TestLoginRateLimit(t *testing.T) {
var l limiter
for i := range loginMaxFailures - 1 {
l.fail("[email protected]")
if l.locked("[email protected]") {
t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures)
}
}
l.fail("[email protected]")
if !l.locked("[email protected]") {
t.Fatalf("not locked after %d failures", loginMaxFailures)
}
// The limit is per email: locking one address must not lock anyone else out.
if l.locked("[email protected]") {
t.Fatal("a different address was locked too")
}
// A correct password clears it, so a member who mistypes nine times and then gets it right
// starts from zero.
l.succeed("[email protected]")
if l.locked("[email protected]") {
t.Fatal("still locked after a successful login")
}
// Failures older than the window don't accumulate.
for range loginMaxFailures - 1 {
l.fail("[email protected]")
}
l.by["[email protected]"].first = time.Now().Add(-loginWindow - time.Minute)
l.fail("[email protected]")
if l.locked("[email protected]") {
t.Fatal("failures outside the window were counted")
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"bytes"
"embed"
"html/template"
"log/slog"
"net/http"
"net/url"
"time"
)
//go:embed templates static
var assetFS embed.FS
var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
}
// Each page is parsed with the layout into its own set, so two pages may both define "content".
var pages = map[string]*template.Template{}
func init() {
entries, err := assetFS.ReadDir("templates")
if err != nil {
panic(err)
}
for _, e := range entries {
if e.Name() == "layout.html" {
continue
}
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name()))
}
}
// page is everything the layout needs, plus whatever the page itself wants in Data.
type page struct {
Title string
Member *member
Admin bool
Flash string
Path string
Data any
}
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
t, ok := pages[name]
if !ok {
slog.Error("unknown template", "name", name)
http.Error(w, "template", http.StatusInternalServerError)
return
}
p.Member = memberFrom(r.Context())
p.Path = r.URL.Path
p.Flash = a.takeFlash(w, r)
// Render to memory first: a template that fails halfway must not leave a half-written 200.
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
slog.Error("render", "name", name, "error", err)
http.Error(w, "template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
func (a *app) flash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("flash")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
msg, err := url.QueryUnescape(c.Value)
if err != nil {
return ""
}
return msg
}
+147
View File
@@ -0,0 +1,147 @@
/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */
:root {
--bg: #121212;
--surface: #1c1c1e;
--surface-2: #26262a;
--border: #35353a;
--text: #ece9e6;
--muted: #9a948d;
--accent: #ff5722;
--accent-2: #c62828;
--error: #ef5350;
--radius: 4px;
/* ponytail: system stack until an Oswald woff2 is vendored into /static. */
--font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
}
h1, h2, h3 {
font-family: var(--font-head);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.6rem;
}
h1 { color: var(--accent); font-size: 1.9rem; }
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
a { color: var(--accent); }
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1.2rem;
background: var(--surface);
border-bottom: 2px solid var(--accent-2);
}
.brand {
font-family: var(--font-head);
font-size: 1.3rem;
text-transform: uppercase;
text-decoration: none;
color: var(--text);
}
nav { display: flex; align-items: center; gap: 1rem; }
nav a { text-decoration: none; }
main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; }
section { margin-bottom: 2.5rem; }
.muted { color: var(--muted); }
.error { color: var(--error); display: block; font-size: 0.9rem; }
.tag {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--accent-2);
padding: 0.1rem 0.4rem;
border-radius: var(--radius);
}
.flash {
max-width: 52rem;
margin: 1rem auto 0;
padding: 0.7rem 1rem;
background: var(--surface-2);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--accent-2);
font-family: var(--font-head);
font-size: 0.85rem;
}
form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; }
form.stack label { display: flex; flex-direction: column; gap: 0.25rem; }
form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; }
input {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.6rem;
font: inherit;
}
input:focus-visible, button:focus-visible, a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button {
background: var(--accent);
color: #150c07;
border: 0;
border-radius: var(--radius);
padding: 0.5rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #ff7043; }
button.link {
background: none;
color: var(--accent);
padding: 0;
font-weight: normal;
text-decoration: underline;
}
table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); }
th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); }
tr.banned { opacity: 0.55; }
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.actions form { display: flex; gap: 0.3rem; }
.actions input { width: 10rem; }
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
.invite { word-break: break-all; }
+56
View File
@@ -0,0 +1,56 @@
{{define "content"}}
<h1>Ylläpito</h1>
<section>
<h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
<table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody>
{{range .Data.Invites}}
<tr>
<td>
{{if .IsValid}}
<a href="{{.Link}}" class="invite">{{.Link}}</a>
{{else}}
<code class="muted">{{.Code}}</code>
{{end}}
</td>
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p>
</section>
<section>
<h2>Jäsenet</h2>
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="tag">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit">Vaihda salasana</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
{{define "content"}}
<h1>Jono</h1>
<p class="muted">Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun
lähetysputki ja arvostelut ovat valmiit.</p>
{{end}}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="fi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Levyraati</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a>
<nav>
{{if .Admin}}
<a href="/admin">Ylläpito</a>
{{else if .Member}}
<a href="/">Jono</a>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
{{else}}
<a href="/login">Kirjaudu</a>
{{end}}
</nav>
</header>
{{with .Flash}}<p class="flash">{{.}}</p>{{end}}
<main>{{template "content" .}}</main>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{{define "content"}}
<h1>Kirjaudu</h1>
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/login" class="stack">
<label>Sähköposti
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
</label>
<label>Salasana
<input type="password" name="password" required autocomplete="current-password">
</label>
<label class="row">
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
</label>
<button type="submit">Kirjaudu</button>
</form>
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
<a href="/register">tästä</a>.</p>
{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
<h1>Liity</h1>
<form method="post" action="/register" class="stack">
<label>Kutsukoodi
<input name="code" value="{{.Data.Code}}" required>
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Nimi
<input name="name" value="{{.Data.Name}}" required maxlength="50">
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Sähköposti
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Salasana
<input type="password" name="password" required autocomplete="new-password">
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
</label>
<button type="submit">Liity</button>
</form>
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
{{end}}