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
+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)
}