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