initial commit
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTestTTL is long enough that nothing expires mid-test.
|
||||
const defaultTestTTL = 5 * time.Minute
|
||||
|
||||
func TestPasswordHashingRoundTrip(t *testing.T) {
|
||||
const password = "correct horse battery staple"
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
if strings.Contains(hash, password) {
|
||||
t.Fatal("the hash contains the plaintext password")
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("hash = %q, want the Argon2id PHC format", hash)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(hash, password)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("the correct password did not verify")
|
||||
}
|
||||
|
||||
ok, err = VerifyPassword(hash, "wrong password entirely")
|
||||
if err != nil {
|
||||
t.Fatalf("verify wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("an incorrect password verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashesAreSalted(t *testing.T) {
|
||||
a, err := HashPassword("same password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := HashPassword("same password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("two hashes of the same password are identical; the salt is not random")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsMalformedHashes(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"", "not-a-hash", "$argon2id$", "$argon2id$v=19$m=1$x$y",
|
||||
"$bcrypt$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA",
|
||||
} {
|
||||
if _, err := VerifyPassword(bad, "password"); err == nil {
|
||||
t.Errorf("VerifyPassword(%q) returned no error for a malformed hash", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"long enough", "a-perfectly-fine-phrase", false},
|
||||
{"exactly the minimum", strings.Repeat("x", 12), true}, // repeated character
|
||||
{"mixed at the minimum", "aB3$xY9!zQ2w", false},
|
||||
{"too short", "short", true},
|
||||
{"empty", "", true},
|
||||
{"contains password", "mypassword123456", true},
|
||||
{"contains the product name", "vibedns-is-great-here", true},
|
||||
{"single repeated character", strings.Repeat("a", 20), true},
|
||||
{"control character", "abcdefghijkl\x00mnop", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tc.in)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected an error, got none")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassword(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
p, err := GeneratePassword(20)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(p) != 20 {
|
||||
t.Fatalf("length = %d, want 20", len(p))
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatal("generated the same password twice")
|
||||
}
|
||||
seen[p] = true
|
||||
|
||||
if err := ValidatePassword(p); err != nil {
|
||||
t.Errorf("a generated password failed the policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Short requests are raised to a safe floor rather than honoured.
|
||||
p, _ := GeneratePassword(4)
|
||||
if len(p) < 12 {
|
||||
t.Errorf("short request produced %d characters, want at least 12", len(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenGeneration(t *testing.T) {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(tok.Secret, "vibedns_") {
|
||||
t.Errorf("secret = %q, want the vibedns_ prefix for secret scanners", tok.Secret)
|
||||
}
|
||||
if len(tok.Prefix) != TokenPrefixLen {
|
||||
t.Errorf("prefix length = %d, want %d", len(tok.Prefix), TokenPrefixLen)
|
||||
}
|
||||
if strings.Contains(tok.Hash, tok.Secret) {
|
||||
t.Error("the stored hash contains the secret")
|
||||
}
|
||||
|
||||
if !VerifyToken(tok.Hash, tok.Secret) {
|
||||
t.Error("the generated token did not verify against its own hash")
|
||||
}
|
||||
if VerifyToken(tok.Hash, "vibedns_someothervalue") {
|
||||
t.Error("a different token verified against the hash")
|
||||
}
|
||||
|
||||
got, err := TokenPrefix(tok.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("prefix: %v", err)
|
||||
}
|
||||
if got != tok.Prefix {
|
||||
t.Errorf("extracted prefix = %q, want %q", got, tok.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAreUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[tok.Secret] {
|
||||
t.Fatal("generated the same token twice")
|
||||
}
|
||||
seen[tok.Secret] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPrefixRejectsShortInput(t *testing.T) {
|
||||
if _, err := TokenPrefix("vibedns_ab"); err == nil {
|
||||
t.Error("expected an error for a truncated token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenLifecycle(t *testing.T) {
|
||||
a := New(nil, nil, []byte("a-test-signing-key-of-sufficient-length"))
|
||||
|
||||
token := a.IssueCSRFToken("admin")
|
||||
if token == "" {
|
||||
t.Fatal("no token issued")
|
||||
}
|
||||
|
||||
if !a.ValidateCSRFToken(token, "admin") {
|
||||
t.Error("a freshly issued token did not validate")
|
||||
}
|
||||
// A token is bound to the account it was issued for.
|
||||
if a.ValidateCSRFToken(token, "someone-else") {
|
||||
t.Error("a token validated for a different account")
|
||||
}
|
||||
if a.ValidateCSRFToken("garbage", "admin") {
|
||||
t.Error("a garbage token validated")
|
||||
}
|
||||
if a.ValidateCSRFToken("", "admin") {
|
||||
t.Error("an empty token validated")
|
||||
}
|
||||
|
||||
// A token signed with a different key must not validate.
|
||||
other := New(nil, nil, []byte("a-completely-different-signing-key-xx"))
|
||||
if other.ValidateCSRFToken(token, "admin") {
|
||||
t.Error("a token validated under a different signing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRehash(t *testing.T) {
|
||||
current, err := HashPassword("some password here")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if NeedsRehash(current) {
|
||||
t.Error("a hash produced with the current parameters should not need rehashing")
|
||||
}
|
||||
// A hash with weaker parameters should be upgraded on next sign-in.
|
||||
weak := "$argon2id$v=19$m=1024,t=1,p=1$c2FsdHNhbHQ$aGFzaGhhc2hoYXNoaGFzaA"
|
||||
if !NeedsRehash(weak) {
|
||||
t.Error("a weak hash should be flagged for rehashing")
|
||||
}
|
||||
if !NeedsRehash("not-a-hash") {
|
||||
t.Error("an unparseable hash should be flagged for rehashing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialCache(t *testing.T) {
|
||||
c := newCredentialCache(defaultTestTTL)
|
||||
const user, pass, hash = "admin", "the password", "stored-hash-value"
|
||||
|
||||
if c.valid(user, pass, hash) {
|
||||
t.Error("an empty cache reported a valid credential")
|
||||
}
|
||||
|
||||
c.store(user, pass, hash)
|
||||
if !c.valid(user, pass, hash) {
|
||||
t.Error("a stored credential did not validate")
|
||||
}
|
||||
if c.valid(user, "wrong password", hash) {
|
||||
t.Error("a wrong password validated against the cache")
|
||||
}
|
||||
// A changed stored hash means the password was rotated: the cached entry
|
||||
// must stop being authoritative immediately.
|
||||
if c.valid(user, pass, "a-different-stored-hash") {
|
||||
t.Error("the cache validated against a stale password hash")
|
||||
}
|
||||
|
||||
c.reset()
|
||||
if c.valid(user, pass, hash) {
|
||||
t.Error("the cache still validated after being reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiter(t *testing.T) {
|
||||
l := newAttemptLimiter(3, defaultTestTTL)
|
||||
const key = "192.0.2.1"
|
||||
|
||||
if !l.allow(key) {
|
||||
t.Fatal("a fresh address was blocked")
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
l.fail(key)
|
||||
}
|
||||
if l.allow(key) {
|
||||
t.Error("the address should be locked out after reaching the failure limit")
|
||||
}
|
||||
|
||||
// A different address is unaffected.
|
||||
if !l.allow("192.0.2.2") {
|
||||
t.Error("an unrelated address was locked out")
|
||||
}
|
||||
|
||||
// A success clears the record.
|
||||
l2 := newAttemptLimiter(3, defaultTestTTL)
|
||||
l2.fail(key)
|
||||
l2.fail(key)
|
||||
l2.succeed(key)
|
||||
l2.fail(key)
|
||||
if !l2.allow(key) {
|
||||
t.Error("a successful sign-in should reset the failure count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibedns/internal/database"
|
||||
"github.com/owen/vibedns/internal/models"
|
||||
"github.com/owen/vibedns/internal/netutil"
|
||||
)
|
||||
|
||||
// Realm is the HTTP Basic authentication realm.
|
||||
const Realm = "vibedns management"
|
||||
|
||||
// PrincipalKind distinguishes an interactive administrator from automation.
|
||||
type PrincipalKind string
|
||||
|
||||
const (
|
||||
KindAdmin PrincipalKind = "admin"
|
||||
KindToken PrincipalKind = "token"
|
||||
)
|
||||
|
||||
// Principal is the authenticated identity attached to a request.
|
||||
type Principal struct {
|
||||
Name string
|
||||
Kind PrincipalKind
|
||||
TokenID int64
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal is the interactive administrator.
|
||||
func (p Principal) IsAdmin() bool { return p.Kind == KindAdmin }
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithPrincipal stores a principal on a request context.
|
||||
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, p)
|
||||
}
|
||||
|
||||
// PrincipalFrom retrieves the principal from a request context.
|
||||
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
||||
p, ok := ctx.Value(ctxKey{}).(Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Authenticator verifies credentials for the web UI and the REST API.
|
||||
type Authenticator struct {
|
||||
db *database.DB
|
||||
log *slog.Logger
|
||||
|
||||
csrfKey []byte
|
||||
verifier *credentialCache
|
||||
attempts *attemptLimiter
|
||||
|
||||
// trusted lists proxies whose X-Forwarded-For header we believe.
|
||||
trustedMu sync.RWMutex
|
||||
trusted *netutil.PrefixSet
|
||||
}
|
||||
|
||||
// New creates an authenticator. csrfKey must be a stable secret; it is
|
||||
// persisted so that tokens issued before a restart stay valid.
|
||||
func New(db *database.DB, log *slog.Logger, csrfKey []byte) *Authenticator {
|
||||
return &Authenticator{
|
||||
db: db,
|
||||
log: log,
|
||||
csrfKey: csrfKey,
|
||||
verifier: newCredentialCache(5 * time.Minute),
|
||||
attempts: newAttemptLimiter(10, 5*time.Minute),
|
||||
trusted: netutil.NewPrefixSet(nil),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTrustedProxies configures which peers may set X-Forwarded-For.
|
||||
func (a *Authenticator) SetTrustedProxies(cidrs []string) {
|
||||
a.trustedMu.Lock()
|
||||
a.trusted = netutil.NewPrefixSet(cidrs)
|
||||
a.trustedMu.Unlock()
|
||||
}
|
||||
|
||||
// ClientIP resolves the client address, honouring X-Forwarded-For only when the
|
||||
// immediate peer is a configured trusted proxy. Trusting the header
|
||||
// unconditionally would let any client forge its own address and bypass the
|
||||
// login rate limiter.
|
||||
func (a *Authenticator) ClientIP(r *http.Request) string {
|
||||
peer, ok := netutil.AddrFromHostPort(r.RemoteAddr)
|
||||
if !ok {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
a.trustedMu.RLock()
|
||||
trusted := a.trusted
|
||||
a.trustedMu.RUnlock()
|
||||
|
||||
if trusted.Contains(peer) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// The left-most entry is the original client.
|
||||
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if addr, err := netip.ParseAddr(first); err == nil {
|
||||
return addr.Unmap().String()
|
||||
}
|
||||
}
|
||||
if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
|
||||
if addr, err := netip.ParseAddr(xr); err == nil {
|
||||
return addr.Unmap().String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
// Errors returned by credential verification.
|
||||
var (
|
||||
ErrUnauthorised = errors.New("authentication required")
|
||||
ErrLockedOut = errors.New("too many failed sign-in attempts")
|
||||
ErrNoAdmin = errors.New("no administrator account exists")
|
||||
)
|
||||
|
||||
// Authenticate verifies the credentials on a request.
|
||||
//
|
||||
// It accepts either HTTP Basic credentials (the interactive administrator) or
|
||||
// a bearer API token. Tokens are rejected for the HTML interface by the caller,
|
||||
// which passes allowTokens=false.
|
||||
func (a *Authenticator) Authenticate(r *http.Request, allowTokens bool) (Principal, error) {
|
||||
clientIP := a.ClientIP(r)
|
||||
if !a.attempts.allow(clientIP) {
|
||||
return Principal{}, ErrLockedOut
|
||||
}
|
||||
|
||||
if allowTokens {
|
||||
if secret, ok := bearerToken(r); ok {
|
||||
p, err := a.verifyToken(r.Context(), secret, clientIP)
|
||||
if err != nil {
|
||||
a.attempts.fail(clientIP)
|
||||
return Principal{}, err
|
||||
}
|
||||
a.attempts.succeed(clientIP)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return Principal{}, ErrUnauthorised
|
||||
}
|
||||
p, err := a.verifyPassword(r.Context(), username, password, clientIP)
|
||||
if err != nil {
|
||||
a.attempts.fail(clientIP)
|
||||
a.log.Warn("failed sign-in attempt", "username", username, "client", clientIP)
|
||||
return Principal{}, err
|
||||
}
|
||||
a.attempts.succeed(clientIP)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyPassword(ctx context.Context, username, password, clientIP string) (Principal, error) {
|
||||
admin, err := a.db.Admin(ctx)
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return Principal{}, ErrNoAdmin
|
||||
}
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("load administrator: %w", err)
|
||||
}
|
||||
|
||||
// Compare the username in constant time so it cannot be probed by timing.
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(admin.Username)) == 1
|
||||
|
||||
// HTTP Basic sends credentials on every request, including every page load.
|
||||
// Running Argon2id each time would cost 64 MiB and tens of milliseconds per
|
||||
// request, so a successful verification is remembered briefly, keyed by a
|
||||
// MAC of the password rather than the password itself.
|
||||
if userOK && a.verifier.valid(username, password, admin.PasswordHash) {
|
||||
return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil
|
||||
}
|
||||
|
||||
passOK, err := VerifyPassword(admin.PasswordHash, password)
|
||||
if err != nil {
|
||||
a.log.Error("stored administrator password hash is unusable", "error", err)
|
||||
return Principal{}, ErrUnauthorised
|
||||
}
|
||||
if !userOK || !passOK {
|
||||
return Principal{}, ErrUnauthorised
|
||||
}
|
||||
|
||||
a.verifier.store(username, password, admin.PasswordHash)
|
||||
_ = a.db.TouchAdminLogin(ctx)
|
||||
return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyToken(ctx context.Context, secret, clientIP string) (Principal, error) {
|
||||
prefix, err := TokenPrefix(secret)
|
||||
if err != nil {
|
||||
return Principal{}, ErrUnauthorised
|
||||
}
|
||||
candidates, err := a.db.APITokensByPrefix(ctx, prefix)
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("look up API token: %w", err)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if VerifyToken(c.Hash, secret) {
|
||||
go func(id int64) {
|
||||
tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = a.db.TouchAPIToken(tctx, id)
|
||||
}(c.ID)
|
||||
return Principal{Name: c.Name, Kind: KindToken, TokenID: c.ID, ClientIP: clientIP}, nil
|
||||
}
|
||||
}
|
||||
return Principal{}, ErrUnauthorised
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")), true
|
||||
}
|
||||
if v := r.Header.Get("X-API-Token"); v != "" {
|
||||
return strings.TrimSpace(v), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// InvalidateCredentials clears the verification cache. It is called after a
|
||||
// password change so the old password stops working immediately.
|
||||
func (a *Authenticator) InvalidateCredentials() { a.verifier.reset() }
|
||||
|
||||
// --- credential cache ---------------------------------------------------
|
||||
|
||||
type cachedCred struct {
|
||||
mac []byte
|
||||
hashSeen string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type credentialCache struct {
|
||||
mu sync.RWMutex
|
||||
key []byte
|
||||
ttl time.Duration
|
||||
items map[string]cachedCred
|
||||
}
|
||||
|
||||
func newCredentialCache(ttl time.Duration) *credentialCache {
|
||||
key := make([]byte, 32)
|
||||
// A failure here is not fatal: an all-zero key only weakens the cache
|
||||
// index, which never leaves this process and is not a stored secret.
|
||||
if s, err := RandomKey(32); err == nil {
|
||||
copy(key, s)
|
||||
}
|
||||
return &credentialCache{key: key, ttl: ttl, items: map[string]cachedCred{}}
|
||||
}
|
||||
|
||||
func (c *credentialCache) mac(password string) []byte {
|
||||
h := hmac.New(sha256.New, c.key)
|
||||
h.Write([]byte(password))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func (c *credentialCache) valid(username, password, currentHash string) bool {
|
||||
c.mu.RLock()
|
||||
item, ok := c.items[username]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(item.expires) {
|
||||
return false
|
||||
}
|
||||
// A changed stored hash means the password was rotated; the cache entry is
|
||||
// no longer authoritative.
|
||||
if item.hashSeen != currentHash {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal(item.mac, c.mac(password))
|
||||
}
|
||||
|
||||
func (c *credentialCache) store(username, password, currentHash string) {
|
||||
c.mu.Lock()
|
||||
c.items[username] = cachedCred{
|
||||
mac: c.mac(password),
|
||||
hashSeen: currentHash,
|
||||
expires: time.Now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *credentialCache) reset() {
|
||||
c.mu.Lock()
|
||||
c.items = map[string]cachedCred{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- failed attempt limiting -------------------------------------------
|
||||
|
||||
type attemptState struct {
|
||||
failures int
|
||||
until time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// attemptLimiter slows down credential guessing per source address.
|
||||
type attemptLimiter struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*attemptState
|
||||
max int
|
||||
lockout time.Duration
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
func newAttemptLimiter(max int, lockout time.Duration) *attemptLimiter {
|
||||
return &attemptLimiter{items: map[string]*attemptState{}, max: max, lockout: lockout}
|
||||
}
|
||||
|
||||
func (l *attemptLimiter) allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.gcLocked()
|
||||
st, ok := l.items[key]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if time.Now().Before(st.until) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *attemptLimiter) fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st, ok := l.items[key]
|
||||
if !ok {
|
||||
st = &attemptState{}
|
||||
l.items[key] = st
|
||||
}
|
||||
st.failures++
|
||||
st.last = time.Now()
|
||||
if st.failures >= l.max {
|
||||
st.until = time.Now().Add(l.lockout)
|
||||
st.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (l *attemptLimiter) succeed(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.items, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// gcLocked drops stale entries so the map cannot grow without bound.
|
||||
func (l *attemptLimiter) gcLocked() {
|
||||
now := time.Now()
|
||||
if now.Sub(l.lastGC) < time.Minute {
|
||||
return
|
||||
}
|
||||
l.lastGC = now
|
||||
for k, st := range l.items {
|
||||
if now.After(st.until) && now.Sub(st.last) > l.lockout {
|
||||
delete(l.items, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSRF ---------------------------------------------------------------
|
||||
|
||||
// CSRFCookieName is the double-submit cookie the browser echoes back.
|
||||
const CSRFCookieName = "vibedns_csrf"
|
||||
|
||||
// CSRFFieldName is the form field carrying the token.
|
||||
const CSRFFieldName = "_csrf"
|
||||
|
||||
// CSRFHeaderName is the header carrying the token for fetch() calls.
|
||||
const CSRFHeaderName = "X-CSRF-Token"
|
||||
|
||||
const csrfTokenTTL = 12 * time.Hour
|
||||
|
||||
// IssueCSRFToken mints a token bound to a user and an expiry.
|
||||
//
|
||||
// HTTP Basic credentials are replayed by the browser on every request,
|
||||
// including cross-site form posts, so Basic auth alone does not protect
|
||||
// state-changing requests. The token is signed, tied to the account, and
|
||||
// double-submitted: an attacker on another origin can neither read the cookie
|
||||
// nor forge the signature.
|
||||
func (a *Authenticator) IssueCSRFToken(username string) string {
|
||||
expiry := time.Now().Add(csrfTokenTTL).Unix()
|
||||
payload := fmt.Sprintf("%s|%d", username, expiry)
|
||||
mac := a.csrfMAC(payload)
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + mac))
|
||||
}
|
||||
|
||||
func (a *Authenticator) csrfMAC(payload string) string {
|
||||
h := hmac.New(sha256.New, a.csrfKey)
|
||||
h.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateCSRFToken checks a token's signature, expiry and account binding.
|
||||
func (a *Authenticator) ValidateCSRFToken(token, username string) bool {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(string(raw), "|")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
payload := parts[0] + "|" + parts[1]
|
||||
if !hmac.Equal([]byte(a.csrfMAC(payload)), []byte(parts[2])) {
|
||||
return false
|
||||
}
|
||||
if parts[0] != username {
|
||||
return false
|
||||
}
|
||||
var expiry int64
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &expiry); err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() < expiry
|
||||
}
|
||||
|
||||
// SetCSRFCookie writes the double-submit cookie.
|
||||
func SetCSRFCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CSRFCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: false, // the page's JavaScript reads it for fetch() calls
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(csrfTokenTTL / time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
// CheckCSRF validates a state-changing browser request.
|
||||
//
|
||||
// The submitted token must be present, correctly signed for this account, and
|
||||
// identical to the cookie value.
|
||||
func (a *Authenticator) CheckCSRF(r *http.Request, p Principal) error {
|
||||
// API tokens are not sent automatically by browsers, so a request
|
||||
// authenticated by one cannot be cross-site forged.
|
||||
if p.Kind == KindToken {
|
||||
return nil
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return nil
|
||||
}
|
||||
|
||||
submitted := r.Header.Get(CSRFHeaderName)
|
||||
if submitted == "" {
|
||||
submitted = r.PostFormValue(CSRFFieldName)
|
||||
}
|
||||
if submitted == "" {
|
||||
return errors.New("this request is missing its CSRF token; reload the page and try again")
|
||||
}
|
||||
cookie, err := r.Cookie(CSRFCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return errors.New("the CSRF cookie is missing; make sure cookies are enabled, then reload the page")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(submitted), []byte(cookie.Value)) != 1 {
|
||||
return errors.New("the CSRF token does not match; reload the page and try again")
|
||||
}
|
||||
if !a.ValidateCSRFToken(submitted, p.Name) {
|
||||
return errors.New("the CSRF token has expired; reload the page and try again")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureAdmin creates the administrator account if one does not exist,
|
||||
// returning the generated password when it had to invent one.
|
||||
func (a *Authenticator) EnsureAdmin(ctx context.Context, username, password string) (created bool, generated string, err error) {
|
||||
if _, err := a.db.Admin(ctx); err == nil {
|
||||
return false, "", nil
|
||||
} else if !errors.Is(err, database.ErrNotFound) {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
mustChange := false
|
||||
if password == "" {
|
||||
password, err = GeneratePassword(20)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
generated = password
|
||||
mustChange = true
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if err := a.db.CreateAdmin(ctx, username, hash, mustChange); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return true, generated, nil
|
||||
}
|
||||
|
||||
// Admin returns the administrator record.
|
||||
func (a *Authenticator) Admin(ctx context.Context) (models.Admin, error) { return a.db.Admin(ctx) }
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package auth handles administrator credentials, API tokens, CSRF protection
|
||||
// and the HTTP middleware that enforces them.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters.
|
||||
//
|
||||
// 64 MiB with three passes is the interactive profile from the Argon2 RFC:
|
||||
// costly enough that an offline attack on a stolen hash is expensive, cheap
|
||||
// enough that a login takes well under a second on the small machines this
|
||||
// server is meant to run on.
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // KiB
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
func argonThreads() uint8 {
|
||||
n := runtime.NumCPU()
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return uint8(n)
|
||||
}
|
||||
|
||||
// ErrInvalidHash is returned when a stored hash cannot be parsed.
|
||||
var ErrInvalidHash = errors.New("stored password hash is malformed")
|
||||
|
||||
// HashPassword derives an Argon2id hash in the standard PHC string format, so
|
||||
// the parameters travel with the hash and can be raised later without
|
||||
// invalidating existing credentials.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("password must not be empty")
|
||||
}
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate password salt: %w", err)
|
||||
}
|
||||
threads := argonThreads()
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a password against a stored PHC hash in constant time.
|
||||
func VerifyPassword(encoded, password string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, fmt.Errorf("%w: unsupported Argon2 version %d", ErrInvalidHash, version)
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
want, err := base64.RawStdEncoding.Strict().DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, ErrInvalidHash
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
// NeedsRehash reports whether a stored hash uses weaker parameters than the
|
||||
// current policy, so it can be upgraded on the next successful login.
|
||||
func NeedsRehash(encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return true
|
||||
}
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return true
|
||||
}
|
||||
return memory < argonMemory || time < argonTime
|
||||
}
|
||||
|
||||
// passwordAlphabet avoids characters that are easy to confuse when a generated
|
||||
// password is read off a terminal and typed into a browser.
|
||||
const passwordAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// GeneratePassword returns a cryptographically random password.
|
||||
func GeneratePassword(length int) (string, error) {
|
||||
if length < 12 {
|
||||
length = 12
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
out := make([]byte, length)
|
||||
for i, b := range buf {
|
||||
out[i] = passwordAlphabet[int(b)%len(passwordAlphabet)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// MinPasswordLength is the shortest password the UI will accept.
|
||||
const MinPasswordLength = 12
|
||||
|
||||
// ValidatePassword enforces a modest password policy. It follows current NIST
|
||||
// guidance: length carries the weight, and arbitrary composition rules are
|
||||
// avoided in favour of rejecting obviously weak choices.
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < MinPasswordLength {
|
||||
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return errors.New("password must be at most 1024 characters")
|
||||
}
|
||||
for _, r := range password {
|
||||
if unicode.IsControl(r) {
|
||||
return errors.New("password must not contain control characters")
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(password)
|
||||
for _, weak := range []string{"password", "12345678", "qwerty", "vibedns", "changeme", "vibedns"} {
|
||||
if strings.Contains(lower, weak) {
|
||||
return fmt.Errorf("password must not contain the common string %q", weak)
|
||||
}
|
||||
}
|
||||
if isSingleRepeatedRune(password) {
|
||||
return errors.New("password must not be a single repeated character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSingleRepeatedRune(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
first := rune(s[0])
|
||||
for _, r := range s {
|
||||
if r != first {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TokenPrefixLen is how many characters of a token are stored in the clear to
|
||||
// narrow the database lookup. It is not a secret: it only identifies which row
|
||||
// to compare against.
|
||||
const TokenPrefixLen = 8
|
||||
|
||||
// tokenLabel prefixes every issued token so a leaked string is recognisable in
|
||||
// logs and secret scanners.
|
||||
const tokenLabel = "vibedns_"
|
||||
|
||||
// Token is a freshly minted API credential.
|
||||
type Token struct {
|
||||
Secret string // shown to the operator exactly once
|
||||
Prefix string // stored in the clear, used to find the row
|
||||
Hash string // stored, never reversible
|
||||
}
|
||||
|
||||
// GenerateToken creates a 256-bit API token.
|
||||
func GenerateToken() (Token, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return Token{}, fmt.Errorf("generate API token: %w", err)
|
||||
}
|
||||
body := base64.RawURLEncoding.EncodeToString(buf)
|
||||
secret := tokenLabel + body
|
||||
|
||||
return Token{
|
||||
Secret: secret,
|
||||
Prefix: body[:TokenPrefixLen],
|
||||
Hash: HashToken(secret),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HashToken hashes an API token with SHA-256.
|
||||
//
|
||||
// Unlike a human-chosen password, an API token is 256 bits of output from a
|
||||
// CSPRNG, so there is no low-entropy guess space for an attacker to search: a
|
||||
// fast hash is sufficient and, unlike Argon2id, can be computed on every API
|
||||
// request without adding tens of milliseconds and 64 MiB of allocation to each
|
||||
// one.
|
||||
func HashToken(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// TokenPrefix extracts the lookup prefix from a presented token.
|
||||
func TokenPrefix(secret string) (string, error) {
|
||||
body := strings.TrimPrefix(secret, tokenLabel)
|
||||
if len(body) < TokenPrefixLen {
|
||||
return "", errors.New("API token is malformed")
|
||||
}
|
||||
return body[:TokenPrefixLen], nil
|
||||
}
|
||||
|
||||
// VerifyToken compares a presented token against a stored hash in constant
|
||||
// time.
|
||||
func VerifyToken(storedHash, secret string) bool {
|
||||
got := HashToken(secret)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(storedHash)) == 1
|
||||
}
|
||||
|
||||
// RandomKey returns n cryptographically random bytes, base64 encoded. It backs
|
||||
// the CSRF signing key.
|
||||
func RandomKey(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate random key: %w", err)
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
Reference in New Issue
Block a user