ScuttleBot

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

Keyboard Shortcuts

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