diff --git a/.env.example b/.env.example index f2eddbe..d0af2b7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 8cac040..eaa8360 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..5587363 --- /dev/null +++ b/admin.go @@ -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) +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..e4ecaf2 --- /dev/null +++ b/auth.go @@ -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" +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..e987f8c --- /dev/null +++ b/auth_test.go @@ -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', 'esa@example.com', '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": {"esa@example.com"}, "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": {"toinen@example.com"}, "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": {"toinen@example.com"}, "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": {"kolmas@example.com"}, "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, "esa@example.com") + + bad := url.Values{"email": {"esa@example.com"}, "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, "esa@example.com") + + 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": {"esa@example.com"}, "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 = 'esa@example.com'`).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": {"esa@example.com"}, "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": {"esa@example.com"}, "password": {"salasana1"}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("login after unban: status = %d, want 303", w.Code) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 95162c5..0998dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 7deeca5..a156ff2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/spec.md b/docs/spec.md index 62fee21..c8dc18c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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. diff --git a/main.go b/main.go index a05a041..5eb566b 100644 --- a/main.go +++ b/main.go @@ -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") @@ -55,8 +60,9 @@ func fatal(msg string, args ...any) { } type app struct { - cfg config - pool *pgxpool.Pool + 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 } diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..b751575 --- /dev/null +++ b/ratelimit.go @@ -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) + } + } +} diff --git a/ratelimit_test.go b/ratelimit_test.go new file mode 100644 index 0000000..7d1e6f1 --- /dev/null +++ b/ratelimit_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" +) + +func TestLoginRateLimit(t *testing.T) { + var l limiter + + for i := range loginMaxFailures - 1 { + l.fail("esa@example.com") + if l.locked("esa@example.com") { + t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures) + } + } + l.fail("esa@example.com") + if !l.locked("esa@example.com") { + 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("toinen@example.com") { + 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("esa@example.com") + if l.locked("esa@example.com") { + t.Fatal("still locked after a successful login") + } + + // Failures older than the window don't accumulate. + for range loginMaxFailures - 1 { + l.fail("esa@example.com") + } + l.by["esa@example.com"].first = time.Now().Add(-loginWindow - time.Minute) + l.fail("esa@example.com") + if l.locked("esa@example.com") { + t.Fatal("failures outside the window were counted") + } +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..7eedab5 --- /dev/null +++ b/render.go @@ -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 +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..36e72f2 --- /dev/null +++ b/static/style.css @@ -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; } diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..bd2ed2e --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,56 @@ +{{define "content"}} +
| Kutsulinkki | Tila | Luotu |
|---|---|---|
+ {{if .IsValid}}
+ {{.Link}}
+ {{else}}
+ {{.Code}}
+ {{end}}
+ |
+ {{if .IsValid}}käyttämätön{{else}}käytetty{{end}} | +{{fidate .CreatedAt}} | +
| Ei kutsuja. | ||
Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.
+| Nimi | Sähköposti | Liittyi | Toiminnot |
|---|---|---|---|
| {{.Name}}{{if .Banned}} estetty{{end}} | +{{.Email}} | +{{fidate .CreatedAt}} | ++ + + | +
| Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle. | |||
Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun + lähetysputki ja arvostelut ovat valmiit.
+{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..2a46c5a --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,29 @@ + + + + + +{{.}}
{{end}} + +{{.}}
{{end}} + + + +Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan + tästä.
+{{end}} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..be76951 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,25 @@ +{{define "content"}} +Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.
+{{end}}