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.
74 lines
1.8 KiB
Go
74 lines
1.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|