Files
hamlab/internal/auth/auth.go
T
Mitchell-JohnsonandClaude Fable 5.1 f3ca3d8f73 Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 01:20:27 -07:00

453 lines
12 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"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"hamlab/internal/config"
"hamlab/internal/store"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
// SessionIdle is how long a session may go unused before it is dropped, whatever its
// expiry says. Every script, curl and headless browser that signs in leaves a session;
// a phone that is used keeps its own alive by being used.
SessionIdle = 7 * 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 { return TokenIndex(token, hashes) >= 0 }
// TokenIndex is which of hashes the presented token matches, -1 for none; every hash is
// compared whatever the answer.
func TokenIndex(token string, hashes []string) int {
h := []byte(HashToken(token))
idx := -1
for i, want := range hashes {
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 {
idx = i
}
}
return idx
}
// ---- users ----
// UserNameRe is what an account may be called: like a Unix login, lower-case.
var UserNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
const MinPasswordLen = 12
// dummyHash is verified against when the named user does not exist, so a login attempt
// takes the same time either way and the form cannot be used to list accounts.
var dummyHash = func() string {
h, err := HashPassword("not-a-real-password")
if err != nil {
return ""
}
return h
}()
// CheckUser verifies a password for one of users. A missing user — or a guest login whose
// time is up — fails in the same time as a wrong password.
func CheckUser(users []config.User, name, pw string) bool {
for _, u := range users {
if u.Name == name {
if u.Expired(time.Now()) {
break
}
return VerifyPassword(u.Hash, pw)
}
}
VerifyPassword(dummyHash, pw)
return false
}
// ---- guest logins ----
// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or
// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand.
const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// randomFrom returns n characters of guestAlphabet, chosen without modulo bias.
func randomFrom(n int) (string, error) {
out := make([]byte, 0, n)
buf := make([]byte, n)
limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet
for len(out) < n {
if _, err := rand.Read(buf); err != nil {
return "", err
}
for _, b := range buf {
if b >= limit {
continue // would favour the first few letters; ask for another byte instead
}
out = append(out, guestAlphabet[int(b)%len(guestAlphabet)])
if len(out) == n {
break
}
}
}
return string(out), nil
}
// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an
// alphabet with nothing ambiguous in it.
func GuestPassword() (string, error) {
var groups []string
for i := 0; i < 4; i++ {
g, err := randomFrom(4)
if err != nil {
return "", err
}
groups = append(groups, g)
}
return strings.Join(groups, "-"), nil
}
// GuestSuffix is the tail of a generated guest name.
func GuestSuffix() (string, error) { return randomFrom(4) }
// ---- sessions ----
// 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, ses := range state.Sessions {
if !live(ses, now) {
delete(state.Sessions, tok)
}
}
}
// live is whether a session still counts: unexpired, and used within SessionIdle. A
// session from before visits were recorded is judged by its creation, or failing that by
// its expiry alone.
func live(ses store.Session, now time.Time) bool {
if !ses.Expires.After(now) {
return false
}
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
return seen.IsZero() || now.Sub(seen) <= SessionIdle
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Create signs user in from ip with agent, returning the cookie token.
func (s *Sessions) Create(user, ip, agent string) (string, error) {
tok, err := randomHex(32)
if err != nil {
return "", err
}
id, err := randomHex(6)
if err != nil {
return "", err
}
if len(agent) > 200 {
agent = agent[:200]
}
now := time.Now()
err = s.st.Update(func(state *store.State) {
pruneExpired(state)
state.Sessions[tok] = store.Session{ID: id, User: user, Expires: now.Add(SessionTTL), Created: now, LastSeen: now, IP: ip, Agent: agent}
})
return tok, err
}
// Lookup reports whether tok is live and whose it is, sliding its expiry and noting the
// visit (persisted at most hourly, so a busy console is not writing the file all day).
func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
if tok == "" {
return store.Session{}, false
}
var ses store.Session
ok, touch := false, false
s.st.View(func(state *store.State) {
found, have := state.Sessions[tok]
if have && live(found, time.Now()) {
ses, ok = found, true
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
}
})
if ok && touch {
_ = s.st.Update(func(state *store.State) {
if x, found := state.Sessions[tok]; found {
x.Expires = time.Now().Add(SessionTTL)
x.LastSeen = time.Now()
if ip != "" {
x.IP = ip
}
state.Sessions[tok] = x
}
})
}
return ses, ok
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
// List is every live session of user, newest first.
func (s *Sessions) List(user string) []store.Session {
var out []store.Session
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.User == user && live(ses, now) {
out = append(out, ses)
}
}
})
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
return out
}
// Count is how many live sessions each user has.
func (s *Sessions) Count() map[string]int {
n := map[string]int{}
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if live(ses, now) {
n[ses.User]++
}
}
})
return n
}
// Revoke ends the session of user with this ID. Reports whether there was one.
func (s *Sessions) Revoke(user, id string) bool {
found := false
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
if ses.User == user && ses.ID == id {
delete(state.Sessions, tok)
found = true
}
}
})
return found
}
// RevokeOthers ends every session of user except the one holding keep. Returns how many.
func (s *Sessions) RevokeOthers(user, keep string) int {
n := 0
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
if ses.User == user && tok != keep {
delete(state.Sessions, tok)
n++
}
}
})
return n
}
// RevokeIdle ends every session of user not seen for longer than idle, except the one
// holding keep. Returns how many.
func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int {
n := 0
now := time.Now()
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
if ses.User == user && tok != keep && now.Sub(seen) > idle {
delete(state.Sessions, tok)
n++
}
}
})
return n
}
// RevokeUser ends every session of user (the account is going away).
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
// 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. Past perClientLock failures, each further failure doubles a
// lockout starting at 30s, capped at 10 minutes.
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 is how many failures one address gets before it is made to wait. Set high
// on purpose: a guest login is a generated password read off a message and typed by hand
// on a phone, and being locked out for fumbling it is worse than the brute-force risk —
// which the doubling backoff past this point, and the global backstop, still cover.
perClientLock = 20
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)
}