A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
2.1 KiB
Go
92 lines
2.1 KiB
Go
// Package auth has password hashing, session tokens and a login rate limiter.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const MinPasswordLength = 8
|
|
|
|
func HashPassword(pw string) (string, error) {
|
|
b, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
|
|
return string(b), err
|
|
}
|
|
|
|
func CheckPassword(hash, pw string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
|
}
|
|
|
|
// dummyHash lets failed lookups of unknown users take as long as real ones.
|
|
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 12)
|
|
|
|
func BurnTime(pw string) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(pw)) }
|
|
|
|
// NewToken returns a random session token and the hash stored server side.
|
|
func NewToken() (token, hash string) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
token = base64.RawURLEncoding.EncodeToString(b)
|
|
return token, HashToken(token)
|
|
}
|
|
|
|
func HashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Limiter allows a fixed number of failed attempts per key per window.
|
|
type Limiter struct {
|
|
mu sync.Mutex
|
|
max int
|
|
window time.Duration
|
|
hits map[string][]time.Time
|
|
}
|
|
|
|
func NewLimiter(max int, window time.Duration) *Limiter {
|
|
return &Limiter{max: max, window: window, hits: map[string][]time.Time{}}
|
|
}
|
|
|
|
func (l *Limiter) prune(key string, now time.Time) []time.Time {
|
|
kept := l.hits[key][:0]
|
|
for _, t := range l.hits[key] {
|
|
if now.Sub(t) < l.window {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
if len(kept) == 0 {
|
|
delete(l.hits, key)
|
|
return nil
|
|
}
|
|
l.hits[key] = kept
|
|
return kept
|
|
}
|
|
|
|
// Blocked reports whether key has used up its attempts.
|
|
func (l *Limiter) Blocked(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return len(l.prune(key, time.Now())) >= l.max
|
|
}
|
|
|
|
func (l *Limiter) Fail(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := time.Now()
|
|
l.hits[key] = append(l.prune(key, now), now)
|
|
}
|
|
|
|
func (l *Limiter) Reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.hits, key)
|
|
}
|