- Fix YAML tags in auth config struct (json -> yaml) - Update CLI configs to use pre-hashed API keys - Remove double hashing in WebSocket client - Fix port mapping (9102 -> 9103) in CLI commands - Update permission keys to use jobs:read, jobs:create, etc. - Clean up all debug logging from CLI and server - All user roles now authenticate correctly: * Admin: Can queue jobs and see all jobs * Researcher: Can queue jobs and see own jobs * Analyst: Can see status (read-only access) Multi-user authentication is now fully functional.
169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/zalando/go-keyring"
|
|
)
|
|
|
|
// KeychainManager provides secure storage for API keys.
|
|
type KeychainManager struct {
|
|
primary systemKeyring
|
|
fallback *fileKeyStore
|
|
}
|
|
|
|
// systemKeyring abstracts go-keyring for easier testing.
|
|
type systemKeyring interface {
|
|
Set(service, account, secret string) error
|
|
Get(service, account string) (string, error)
|
|
Delete(service, account string) error
|
|
}
|
|
|
|
type goKeyring struct{}
|
|
|
|
func (goKeyring) Set(service, account, secret string) error {
|
|
return keyring.Set(service, account, secret)
|
|
}
|
|
|
|
func (goKeyring) Get(service, account string) (string, error) {
|
|
return keyring.Get(service, account)
|
|
}
|
|
|
|
func (goKeyring) Delete(service, account string) error {
|
|
return keyring.Delete(service, account)
|
|
}
|
|
|
|
// NewKeychainManager returns a manager backed by the OS keyring with a secure file fallback.
|
|
func NewKeychainManager() *KeychainManager {
|
|
return newKeychainManagerWithKeyring(goKeyring{}, defaultFallbackDir())
|
|
}
|
|
|
|
func newKeychainManagerWithKeyring(kr systemKeyring, fallbackDir string) *KeychainManager {
|
|
if fallbackDir == "" {
|
|
fallbackDir = defaultFallbackDir()
|
|
}
|
|
return &KeychainManager{
|
|
primary: kr,
|
|
fallback: newFileKeyStore(fallbackDir),
|
|
}
|
|
}
|
|
|
|
func defaultFallbackDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return filepath.Join(os.TempDir(), "fetch_ml", "keys")
|
|
}
|
|
return filepath.Join(home, ".fetch_ml", "keys")
|
|
}
|
|
|
|
// StoreAPIKey stores the key in the OS keyring, falling back to a protected file when needed.
|
|
func (km *KeychainManager) StoreAPIKey(service, account, key string) error {
|
|
if err := km.primary.Set(service, account, key); err == nil {
|
|
return nil
|
|
} else if errors.Is(err, keyring.ErrUnsupportedPlatform) || errors.Is(err, keyring.ErrNotFound) {
|
|
return km.fallback.store(service, account, key)
|
|
} else if fallbackErr := km.fallback.store(service, account, key); fallbackErr == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to store API key")
|
|
}
|
|
|
|
// GetAPIKey retrieves a key from the OS keyring or fallback store.
|
|
func (km *KeychainManager) GetAPIKey(service, account string) (string, error) {
|
|
secret, err := km.primary.Get(service, account)
|
|
if err == nil {
|
|
return secret, nil
|
|
}
|
|
if errors.Is(err, keyring.ErrUnsupportedPlatform) || errors.Is(err, keyring.ErrNotFound) {
|
|
return km.fallback.get(service, account)
|
|
}
|
|
// Unknown error - try fallback before surfacing
|
|
if fallbackSecret, ferr := km.fallback.get(service, account); ferr == nil {
|
|
return fallbackSecret, nil
|
|
}
|
|
return "", fmt.Errorf("failed to retrieve API key")
|
|
}
|
|
|
|
// DeleteAPIKey removes a key from both stores.
|
|
func (km *KeychainManager) DeleteAPIKey(service, account string) error {
|
|
if err := km.primary.Delete(service, account); err != nil &&
|
|
!errors.Is(err, keyring.ErrNotFound) &&
|
|
!errors.Is(err, keyring.ErrUnsupportedPlatform) {
|
|
return fmt.Errorf("failed to delete API key: %w", err)
|
|
}
|
|
if err := km.fallback.delete(service, account); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsAvailable reports whether the OS keyring backend is usable.
|
|
func (km *KeychainManager) IsAvailable() bool {
|
|
_, err := km.primary.Get("fetch_ml_probe", fmt.Sprintf("probe_%d", time.Now().UnixNano()))
|
|
return err == nil || !errors.Is(err, keyring.ErrUnsupportedPlatform)
|
|
}
|
|
|
|
// ListAvailableMethods returns backends the manager can use.
|
|
func (km *KeychainManager) ListAvailableMethods() []string {
|
|
methods := []string{}
|
|
if km.IsAvailable() {
|
|
methods = append(methods, "OS keyring")
|
|
}
|
|
methods = append(methods, fmt.Sprintf("Encrypted file (%s)", km.fallback.baseDir))
|
|
return methods
|
|
}
|
|
|
|
// fileKeyStore stores secrets with 0600 permissions as a fallback.
|
|
type fileKeyStore struct {
|
|
baseDir string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newFileKeyStore(baseDir string) *fileKeyStore {
|
|
return &fileKeyStore{baseDir: baseDir}
|
|
}
|
|
|
|
func (f *fileKeyStore) store(service, account, secret string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := os.MkdirAll(f.baseDir, 0o700); err != nil {
|
|
return fmt.Errorf("failed to prepare key store: %w", err)
|
|
}
|
|
path := f.path(service, account)
|
|
return os.WriteFile(path, []byte(secret), 0o600)
|
|
}
|
|
|
|
func (f *fileKeyStore) get(service, account string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
data, err := os.ReadFile(f.path(service, account))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func (f *fileKeyStore) delete(service, account string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
path := f.path(service, account)
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fileKeyStore) path(service, account string) string {
|
|
return filepath.Join(f.baseDir, fmt.Sprintf("%s_%s.key", sanitize(service), sanitize(account)))
|
|
}
|
|
|
|
func sanitize(value string) string {
|
|
replacer := strings.NewReplacer("/", "_", "\\", "_", "..", "_", " ", "_", "\t", "_")
|
|
return replacer.Replace(value)
|
|
}
|