Files
hamlab/internal/auth/auth.go
T

253 lines
5.8 KiB
Go

// Package auth: argon2id password hashing, cookie sessions, login rate limiting.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"hamlab/internal/store"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
)
func HashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil
}
func VerifyPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// ---- API tokens (hub → node) ----
// NewToken returns a random bearer token and its SHA-256 hex, which is what gets stored.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
token = base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// TokenMatches compares a presented token against stored hashes in constant time.
func TokenMatches(token string, hashes []string) bool {
h := []byte(HashToken(token))
ok := false
for _, want := range hashes {
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 {
ok = true
}
}
return ok
}
// Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct {
st *store.Store
}
func NewSessions(st *store.Store) *Sessions {
s := &Sessions{st: st}
_ = st.Update(func(state *store.State) { pruneExpired(state) })
return s
}
func pruneExpired(state *store.State) {
now := time.Now()
for tok, exp := range state.Sessions {
if exp.Before(now) {
delete(state.Sessions, tok)
}
}
}
func (s *Sessions) Create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
tok := hex.EncodeToString(b)
err := s.st.Update(func(state *store.State) {
pruneExpired(state)
state.Sessions[tok] = time.Now().Add(SessionTTL)
})
return tok, err
}
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
func (s *Sessions) Valid(tok string) bool {
if tok == "" {
return false
}
ok := false
extend := false
s.st.View(func(state *store.State) {
exp, found := state.Sessions[tok]
if found && exp.After(time.Now()) {
ok = true
extend = time.Until(exp) < SessionTTL-time.Hour
}
})
if ok && extend {
_ = s.st.Update(func(state *store.State) {
if _, found := state.Sessions[tok]; found {
state.Sessions[tok] = time.Now().Add(SessionTTL)
}
})
}
return ok
}
// DeleteAll ends every session on every device and says how many there were.
func (s *Sessions) DeleteAll() (n int) {
_ = s.st.Update(func(state *store.State) {
n = len(state.Sessions)
state.Sessions = map[string]time.Time{}
})
return n
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
// Limiter is a single global backoff: after 5 failures, each further failure
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
// Limiter locks out a client after repeated failed logins, per client address, with a
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
// address never blocks another.
type Limiter struct {
mu sync.Mutex
clients map[string]*bucket
global bucket
}
type bucket struct {
failures int
lockUntil time.Time
last time.Time
}
var ErrLocked = errors.New("too many failed logins")
const (
perClientLock = 5 // failures before a client is locked
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
globalWindow = 5 * time.Minute
)
func (l *Limiter) get(key string) *bucket {
if l.clients == nil {
l.clients = map[string]*bucket{}
}
b, ok := l.clients[key]
if !ok {
b = &bucket{}
l.clients[key] = b
}
// forget clients that have been quiet for an hour
if len(l.clients) > 256 {
for k, x := range l.clients {
if time.Since(x.last) > time.Hour && time.Now().After(x.lockUntil) {
delete(l.clients, k)
}
}
}
return b
}
func (l *Limiter) Allow(key string) error {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if now.Before(l.global.lockUntil) {
return ErrLocked
}
if now.Sub(l.global.last) > globalWindow {
l.global.failures = 0
}
if b := l.get(key); now.Before(b.lockUntil) {
return ErrLocked
}
return nil
}
func (l *Limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b := l.get(key)
b.failures++
b.last = now
if b.failures >= perClientLock {
d := 30 * time.Second << uint(b.failures-perClientLock)
if d > 10*time.Minute {
d = 10 * time.Minute
}
b.lockUntil = now.Add(d)
}
if now.Sub(l.global.last) > globalWindow {
l.global.failures = 0
}
l.global.failures++
l.global.last = now
if l.global.failures >= globalLock {
l.global.lockUntil = now.Add(time.Minute)
l.global.failures = 0
}
}
func (l *Limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.clients, key)
}