Thirty-seven entries in the root, most of them .go files. The assets had to come along: //go:embed cannot reach outside its own directory, so templates/, static/ and migrations/ live beside the code that embeds them, and testdata/ beside the test that reads it. storage/ stays put — runtime data, not source. go build now needs -o. Without it the output would be named after the package directory and collide with src/ itself.
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)
|
|
}
|
|
}
|
|
}
|