Report queued tasks the scheduling pass never considers
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>
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user