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
+28 -5
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")
@@ -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
}