4fbf3ac966
A task in retry backoff was filtered out before the candidate loop, so it recorded no rejection at all: queued, apparently assignable, and silent. That is the exact shape that made F5 take a live session to diagnose. It now reports "retry backoff until <time>". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
9.2 KiB
Go
344 lines
9.2 KiB
Go
// Package authn owns browser-operator identities and credential verification.
|
|
// Authorization policy remains in authz; this package only proves who signed
|
|
// in. Operator records live in a small embedded bbolt database so a deployment
|
|
// never needs to carry a reusable password hash in its environment.
|
|
package authn
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
DatabaseFile = "auth.db"
|
|
MinimumPassword = 10
|
|
maximumPassword = 72 // bcrypt rejects passwords longer than 72 bytes.
|
|
maximumUsername = 128
|
|
databaseOpenWait = 2 * time.Second
|
|
databaseFileMode = 0600
|
|
databaseDirectory = 0700
|
|
)
|
|
|
|
var (
|
|
usersBucket = []byte("operator_users")
|
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
|
ErrUsernameExists = errors.New("username already exists")
|
|
// Unknown users still take a bcrypt comparison. The hash is generated once
|
|
// at process start with the same cost used for real records so the login
|
|
// response does not disclose whether an account exists.
|
|
dummyPasswordHash = func() []byte {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("orchestra-invalid-login-sentinel"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return hash
|
|
}()
|
|
)
|
|
|
|
type User struct {
|
|
Username string `json:"username"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type storedUser struct {
|
|
User
|
|
PasswordHash string `json:"password_hash"`
|
|
}
|
|
|
|
type Store struct {
|
|
db *bolt.DB
|
|
}
|
|
|
|
func Path(dataDir string) string { return filepath.Join(dataDir, DatabaseFile) }
|
|
|
|
func Open(path string) (*Store, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, errors.New("auth database path is required")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), databaseDirectory); err != nil {
|
|
return nil, fmt.Errorf("create auth database directory: %w", err)
|
|
}
|
|
db, err := bolt.Open(path, databaseFileMode, &bolt.Options{Timeout: databaseOpenWait})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open auth database: %w", err)
|
|
}
|
|
s := &Store{db: db}
|
|
if err := db.Update(func(tx *bolt.Tx) error {
|
|
_, err := tx.CreateBucketIfNotExists(usersBucket)
|
|
return err
|
|
}); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("initialize auth database: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
if s == nil || s.db == nil {
|
|
return nil
|
|
}
|
|
return s.db.Close()
|
|
}
|
|
|
|
func normalizedUsername(username string) string {
|
|
return strings.ToLower(strings.TrimSpace(username))
|
|
}
|
|
|
|
func ValidateUsername(username string) error {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return errors.New("username is required")
|
|
}
|
|
if len(username) > maximumUsername {
|
|
return fmt.Errorf("username must be at most %d bytes", maximumUsername)
|
|
}
|
|
if !utf8.ValidString(username) {
|
|
return errors.New("username must be valid UTF-8")
|
|
}
|
|
for _, r := range username {
|
|
if unicode.IsControl(r) {
|
|
return errors.New("username must not contain control characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidatePassword(password string) error {
|
|
if len(password) < MinimumPassword {
|
|
return fmt.Errorf("password must be at least %d characters", MinimumPassword)
|
|
}
|
|
if len([]byte(password)) > maximumPassword {
|
|
return fmt.Errorf("password must be at most %d bytes", maximumPassword)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeUser(raw []byte) (storedUser, error) {
|
|
var user storedUser
|
|
if err := json.Unmarshal(raw, &user); err != nil {
|
|
return storedUser{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Store) Count() (int, error) {
|
|
count := 0
|
|
err := s.db.View(func(tx *bolt.Tx) error {
|
|
count = tx.Bucket(usersBucket).Stats().KeyN
|
|
return nil
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
func (s *Store) Users() ([]User, error) {
|
|
users := []User{}
|
|
err := s.db.View(func(tx *bolt.Tx) error {
|
|
return tx.Bucket(usersBucket).ForEach(func(_, raw []byte) error {
|
|
stored, err := decodeUser(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users = append(users, stored.User)
|
|
return nil
|
|
})
|
|
})
|
|
return users, err
|
|
}
|
|
|
|
func (s *Store) User(username string) (User, error) {
|
|
var out User
|
|
err := s.db.View(func(tx *bolt.Tx) error {
|
|
raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username)))
|
|
if raw == nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
stored, err := decodeUser(raw)
|
|
if err == nil {
|
|
out = stored.User
|
|
}
|
|
return err
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// SetPassword creates an operator or replaces that operator's password. It is
|
|
// intended for the local orchestra-user command; browser changes use Update,
|
|
// which also proves the current password.
|
|
func (s *Store) SetPassword(username, password string) (User, bool, error) {
|
|
username = strings.TrimSpace(username)
|
|
if err := ValidateUsername(username); err != nil {
|
|
return User{}, false, err
|
|
}
|
|
if err := ValidatePassword(password); err != nil {
|
|
return User{}, false, err
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return User{}, false, err
|
|
}
|
|
return s.putHash(username, string(hash), false)
|
|
}
|
|
|
|
// ImportBcrypt is the one-time compatibility bridge from the old environment
|
|
// credential. It only creates the named user when the database is empty.
|
|
func (s *Store) ImportBcrypt(username, passwordHash string) (bool, error) {
|
|
username = strings.TrimSpace(username)
|
|
if err := ValidateUsername(username); err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
|
return false, fmt.Errorf("legacy web password hash must be bcrypt: %w", err)
|
|
}
|
|
_, created, err := s.putHash(username, passwordHash, true)
|
|
return created, err
|
|
}
|
|
|
|
func (s *Store) putHash(username, passwordHash string, onlyIfEmpty bool) (User, bool, error) {
|
|
now := time.Now().UTC()
|
|
key := []byte(normalizedUsername(username))
|
|
var out User
|
|
created := false
|
|
err := s.db.Update(func(tx *bolt.Tx) error {
|
|
bucket := tx.Bucket(usersBucket)
|
|
if onlyIfEmpty && bucket.Stats().KeyN != 0 {
|
|
return nil
|
|
}
|
|
stored := storedUser{User: User{Username: username, CreatedAt: now, UpdatedAt: now}, PasswordHash: passwordHash}
|
|
if raw := bucket.Get(key); raw != nil {
|
|
current, err := decodeUser(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stored.CreatedAt = current.CreatedAt
|
|
} else {
|
|
created = true
|
|
}
|
|
encoded, err := json.Marshal(stored)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = stored.User
|
|
return bucket.Put(key, encoded)
|
|
})
|
|
return out, created, err
|
|
}
|
|
|
|
func (s *Store) Authenticate(username, password string) (User, error) {
|
|
var stored storedUser
|
|
found := false
|
|
err := s.db.View(func(tx *bolt.Tx) error {
|
|
raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username)))
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
var err error
|
|
stored, err = decodeUser(raw)
|
|
found = err == nil
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
hash := dummyPasswordHash
|
|
if found {
|
|
hash = []byte(stored.PasswordHash)
|
|
}
|
|
passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
|
|
usernameOK := found && subtle.ConstantTimeCompare(
|
|
[]byte(normalizedUsername(username)),
|
|
[]byte(normalizedUsername(stored.Username)),
|
|
) == 1
|
|
if !passwordOK || !usernameOK {
|
|
return User{}, ErrInvalidCredentials
|
|
}
|
|
return stored.User, nil
|
|
}
|
|
|
|
// Update changes the authenticated operator's username and/or password. The
|
|
// current password is required even though the endpoint also requires a live
|
|
// browser session, protecting an unattended unlocked browser.
|
|
func (s *Store) Update(currentUsername, currentPassword, newUsername, newPassword string) (User, error) {
|
|
current, err := s.Authenticate(currentUsername, currentPassword)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
newUsername = strings.TrimSpace(newUsername)
|
|
if newUsername == "" {
|
|
newUsername = current.Username
|
|
}
|
|
if err := ValidateUsername(newUsername); err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
var newHash string
|
|
if newPassword != "" {
|
|
if err := ValidatePassword(newPassword); err != nil {
|
|
return User{}, err
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
newHash = string(hash)
|
|
}
|
|
|
|
oldKey := []byte(normalizedUsername(current.Username))
|
|
newKey := []byte(normalizedUsername(newUsername))
|
|
var out User
|
|
err = s.db.Update(func(tx *bolt.Tx) error {
|
|
bucket := tx.Bucket(usersBucket)
|
|
raw := bucket.Get(oldKey)
|
|
if raw == nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
stored, err := decodeUser(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Refuse a stale credential update if another password change landed
|
|
// between Authenticate and this write transaction.
|
|
if bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte(currentPassword)) != nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
if !bytesEqual(oldKey, newKey) && bucket.Get(newKey) != nil {
|
|
return ErrUsernameExists
|
|
}
|
|
stored.Username = newUsername
|
|
stored.UpdatedAt = time.Now().UTC()
|
|
if newHash != "" {
|
|
stored.PasswordHash = newHash
|
|
}
|
|
encoded, err := json.Marshal(stored)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytesEqual(oldKey, newKey) {
|
|
if err := bucket.Delete(oldKey); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := bucket.Put(newKey, encoded); err != nil {
|
|
return err
|
|
}
|
|
out = stored.User
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
func bytesEqual(a, b []byte) bool {
|
|
return len(a) == len(b) && subtle.ConstantTimeCompare(a, b) == 1
|
|
}
|