The login is a user name and a password. The old single password becomes the account named after the OS user on first boot, along with its sessions and the machine-wide look. Settings gains Users (add, remove, reset a password — your own password confirms) and Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents and the dashboard layout follow the account; the refresh rate, graph history and alerts stay the machine's. Pairing a machine takes a user on it. Log lines name the actor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
364 lines
8.9 KiB
Go
364 lines
8.9 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
|
|
)
|
|
|
|
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 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 {
|
|
return VerifyPassword(u.Hash, pw)
|
|
}
|
|
}
|
|
VerifyPassword(dummyHash, pw)
|
|
return false
|
|
}
|
|
|
|
// ---- 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 ses.Expires.Before(now) {
|
|
delete(state.Sessions, tok)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 && found.Expires.After(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 && ses.Expires.After(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 ses.Expires.After(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
|
|
}
|
|
|
|
// RevokeUser ends every session of user (the account is going away).
|
|
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
|
|
|
// 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)
|
|
}
|