|
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 |
} |