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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user