ScuttleBot

scuttlebot / internal / auth / admin.go
Source Blame History 168 lines
5ac549c… lmata 1 // Package auth provides admin account management with bcrypt-hashed passwords.
5ac549c… lmata 2 package auth
5ac549c… lmata 3
5ac549c… lmata 4 import (
5ac549c… lmata 5 "encoding/json"
5ac549c… lmata 6 "fmt"
5ac549c… lmata 7 "os"
5ac549c… lmata 8 "sync"
5ac549c… lmata 9 "time"
5ac549c… lmata 10
5ac549c… lmata 11 "golang.org/x/crypto/bcrypt"
0e78954… lmata 12
0e78954… lmata 13 "github.com/conflicthq/scuttlebot/internal/store"
5ac549c… lmata 14 )
5ac549c… lmata 15
5ac549c… lmata 16 // Admin is a single admin account record.
5ac549c… lmata 17 type Admin struct {
5ac549c… lmata 18 Username string `json:"username"`
5ac549c… lmata 19 Hash []byte `json:"hash"`
5ac549c… lmata 20 Created time.Time `json:"created"`
5ac549c… lmata 21 }
5ac549c… lmata 22
0e78954… lmata 23 // AdminStore persists admin accounts to a JSON file or database.
5ac549c… lmata 24 type AdminStore struct {
5ac549c… lmata 25 mu sync.RWMutex
5ac549c… lmata 26 path string
5ac549c… lmata 27 data []Admin
0e78954… lmata 28 db *store.Store // when non-nil, supersedes path
5ac549c… lmata 29 }
5ac549c… lmata 30
5ac549c… lmata 31 // NewAdminStore loads (or creates) the admin store at the given path.
5ac549c… lmata 32 func NewAdminStore(path string) (*AdminStore, error) {
5ac549c… lmata 33 s := &AdminStore{path: path}
5ac549c… lmata 34 if err := s.load(); err != nil {
5ac549c… lmata 35 return nil, err
5ac549c… lmata 36 }
5ac549c… lmata 37 return s, nil
0e78954… lmata 38 }
0e78954… lmata 39
0e78954… lmata 40 // SetStore switches the admin store to database-backed persistence. All current
0e78954… lmata 41 // in-memory state is replaced with rows loaded from the store.
0e78954… lmata 42 func (s *AdminStore) SetStore(db *store.Store) error {
0e78954… lmata 43 rows, err := db.AdminList()
0e78954… lmata 44 if err != nil {
0e78954… lmata 45 return fmt.Errorf("admin store: load from db: %w", err)
0e78954… lmata 46 }
0e78954… lmata 47 s.mu.Lock()
0e78954… lmata 48 defer s.mu.Unlock()
0e78954… lmata 49 s.db = db
0e78954… lmata 50 s.data = make([]Admin, len(rows))
0e78954… lmata 51 for i, r := range rows {
0e78954… lmata 52 s.data[i] = Admin{Username: r.Username, Hash: r.Hash, Created: r.CreatedAt}
0e78954… lmata 53 }
0e78954… lmata 54 return nil
5ac549c… lmata 55 }
5ac549c… lmata 56
5ac549c… lmata 57 // IsEmpty reports whether there are no admin accounts.
5ac549c… lmata 58 func (s *AdminStore) IsEmpty() bool {
5ac549c… lmata 59 s.mu.RLock()
5ac549c… lmata 60 defer s.mu.RUnlock()
5ac549c… lmata 61 return len(s.data) == 0
5ac549c… lmata 62 }
5ac549c… lmata 63
5ac549c… lmata 64 // Add adds a new admin account. Returns an error if the username already exists.
5ac549c… lmata 65 func (s *AdminStore) Add(username, password string) error {
5ac549c… lmata 66 s.mu.Lock()
5ac549c… lmata 67 defer s.mu.Unlock()
5ac549c… lmata 68
5ac549c… lmata 69 for _, a := range s.data {
5ac549c… lmata 70 if a.Username == username {
5ac549c… lmata 71 return fmt.Errorf("admin %q already exists", username)
5ac549c… lmata 72 }
5ac549c… lmata 73 }
5ac549c… lmata 74
5ac549c… lmata 75 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
5ac549c… lmata 76 if err != nil {
5ac549c… lmata 77 return fmt.Errorf("admin: hash password: %w", err)
5ac549c… lmata 78 }
5ac549c… lmata 79
0e78954… lmata 80 a := Admin{Username: username, Hash: hash, Created: time.Now().UTC()}
0e78954… lmata 81 s.data = append(s.data, a)
0e78954… lmata 82 if s.db != nil {
0e78954… lmata 83 return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: a.Hash, CreatedAt: a.Created})
0e78954… lmata 84 }
5ac549c… lmata 85 return s.save()
5ac549c… lmata 86 }
5ac549c… lmata 87
5ac549c… lmata 88 // SetPassword updates the password for an existing admin.
5ac549c… lmata 89 func (s *AdminStore) SetPassword(username, password string) error {
5ac549c… lmata 90 s.mu.Lock()
5ac549c… lmata 91 defer s.mu.Unlock()
5ac549c… lmata 92
5ac549c… lmata 93 for i, a := range s.data {
5ac549c… lmata 94 if a.Username == username {
5ac549c… lmata 95 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
5ac549c… lmata 96 if err != nil {
5ac549c… lmata 97 return fmt.Errorf("admin: hash password: %w", err)
5ac549c… lmata 98 }
5ac549c… lmata 99 s.data[i].Hash = hash
0e78954… lmata 100 if s.db != nil {
0e78954… lmata 101 return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: hash, CreatedAt: a.Created})
0e78954… lmata 102 }
5ac549c… lmata 103 return s.save()
5ac549c… lmata 104 }
5ac549c… lmata 105 }
5ac549c… lmata 106 return fmt.Errorf("admin %q not found", username)
5ac549c… lmata 107 }
5ac549c… lmata 108
5ac549c… lmata 109 // Remove removes an admin account. Returns an error if not found.
5ac549c… lmata 110 func (s *AdminStore) Remove(username string) error {
5ac549c… lmata 111 s.mu.Lock()
5ac549c… lmata 112 defer s.mu.Unlock()
5ac549c… lmata 113
5ac549c… lmata 114 for i, a := range s.data {
5ac549c… lmata 115 if a.Username == username {
5ac549c… lmata 116 s.data = append(s.data[:i], s.data[i+1:]...)
0e78954… lmata 117 if s.db != nil {
0e78954… lmata 118 return s.db.AdminDelete(username)
0e78954… lmata 119 }
5ac549c… lmata 120 return s.save()
5ac549c… lmata 121 }
5ac549c… lmata 122 }
5ac549c… lmata 123 return fmt.Errorf("admin %q not found", username)
5ac549c… lmata 124 }
5ac549c… lmata 125
5ac549c… lmata 126 // List returns a snapshot of all admin accounts.
5ac549c… lmata 127 func (s *AdminStore) List() []Admin {
5ac549c… lmata 128 s.mu.RLock()
5ac549c… lmata 129 defer s.mu.RUnlock()
5ac549c… lmata 130 out := make([]Admin, len(s.data))
5ac549c… lmata 131 copy(out, s.data)
5ac549c… lmata 132 return out
5ac549c… lmata 133 }
5ac549c… lmata 134
5ac549c… lmata 135 // Authenticate returns true if the username/password pair is valid.
5ac549c… lmata 136 func (s *AdminStore) Authenticate(username, password string) bool {
5ac549c… lmata 137 s.mu.RLock()
5ac549c… lmata 138 defer s.mu.RUnlock()
5ac549c… lmata 139
5ac549c… lmata 140 for _, a := range s.data {
5ac549c… lmata 141 if a.Username == username {
5ac549c… lmata 142 return bcrypt.CompareHashAndPassword(a.Hash, []byte(password)) == nil
5ac549c… lmata 143 }
5ac549c… lmata 144 }
5ac549c… lmata 145 return false
5ac549c… lmata 146 }
5ac549c… lmata 147
5ac549c… lmata 148 func (s *AdminStore) load() error {
5ac549c… lmata 149 raw, err := os.ReadFile(s.path)
5ac549c… lmata 150 if os.IsNotExist(err) {
5ac549c… lmata 151 return nil
5ac549c… lmata 152 }
5ac549c… lmata 153 if err != nil {
5ac549c… lmata 154 return fmt.Errorf("admin store: read %s: %w", s.path, err)
5ac549c… lmata 155 }
5ac549c… lmata 156 if err := json.Unmarshal(raw, &s.data); err != nil {
5ac549c… lmata 157 return fmt.Errorf("admin store: parse: %w", err)
5ac549c… lmata 158 }
5ac549c… lmata 159 return nil
5ac549c… lmata 160 }
5ac549c… lmata 161
5ac549c… lmata 162 func (s *AdminStore) save() error {
5ac549c… lmata 163 raw, err := json.MarshalIndent(s.data, "", " ")
5ac549c… lmata 164 if err != nil {
5ac549c… lmata 165 return err
5ac549c… lmata 166 }
5ac549c… lmata 167 return os.WriteFile(s.path, raw, 0600)
5ac549c… lmata 168 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button