|
1
|
package api |
|
2
|
|
|
3
|
import ( |
|
4
|
"encoding/json" |
|
5
|
"net/http" |
|
6
|
"sync" |
|
7
|
"time" |
|
8
|
|
|
9
|
"github.com/conflicthq/scuttlebot/internal/auth" |
|
10
|
) |
|
11
|
|
|
12
|
// adminStore is the interface the Server uses for admin operations. |
|
13
|
type adminStore interface { |
|
14
|
Authenticate(username, password string) bool |
|
15
|
List() []auth.Admin |
|
16
|
Add(username, password string) error |
|
17
|
Remove(username string) error |
|
18
|
SetPassword(username, password string) error |
|
19
|
} |
|
20
|
|
|
21
|
// loginRateLimiter enforces a per-IP sliding window of 10 attempts per minute. |
|
22
|
type loginRateLimiter struct { |
|
23
|
mu sync.Mutex |
|
24
|
windows map[string][]time.Time |
|
25
|
} |
|
26
|
|
|
27
|
func newLoginRateLimiter() *loginRateLimiter { |
|
28
|
return &loginRateLimiter{windows: make(map[string][]time.Time)} |
|
29
|
} |
|
30
|
|
|
31
|
// Allow returns true if the IP is within the allowed rate. |
|
32
|
func (rl *loginRateLimiter) Allow(ip string) bool { |
|
33
|
rl.mu.Lock() |
|
34
|
defer rl.mu.Unlock() |
|
35
|
|
|
36
|
const ( |
|
37
|
maxAttempts = 10 |
|
38
|
window = time.Minute |
|
39
|
) |
|
40
|
|
|
41
|
now := time.Now() |
|
42
|
cutoff := now.Add(-window) |
|
43
|
|
|
44
|
prev := rl.windows[ip] |
|
45
|
var kept []time.Time |
|
46
|
for _, t := range prev { |
|
47
|
if t.After(cutoff) { |
|
48
|
kept = append(kept, t) |
|
49
|
} |
|
50
|
} |
|
51
|
kept = append(kept, now) |
|
52
|
rl.windows[ip] = kept |
|
53
|
|
|
54
|
return len(kept) <= maxAttempts |
|
55
|
} |
|
56
|
|
|
57
|
func clientIP(r *http.Request) string { |
|
58
|
// Use RemoteAddr; X-Forwarded-For is not trustworthy without proxy config. |
|
59
|
host := r.RemoteAddr |
|
60
|
// Strip port if present. |
|
61
|
for i := len(host) - 1; i >= 0; i-- { |
|
62
|
if host[i] == ':' { |
|
63
|
host = host[:i] |
|
64
|
break |
|
65
|
} |
|
66
|
} |
|
67
|
return host |
|
68
|
} |
|
69
|
|
|
70
|
// handleLogin handles POST /login. |
|
71
|
// Unauthenticated. Returns {token, username} on success. |
|
72
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { |
|
73
|
if s.admins == nil { |
|
74
|
writeError(w, http.StatusNotFound, "admin authentication not configured") |
|
75
|
return |
|
76
|
} |
|
77
|
|
|
78
|
ip := clientIP(r) |
|
79
|
if !s.loginRL.Allow(ip) { |
|
80
|
writeError(w, http.StatusTooManyRequests, "too many login attempts") |
|
81
|
return |
|
82
|
} |
|
83
|
|
|
84
|
var req struct { |
|
85
|
Username string `json:"username"` |
|
86
|
Password string `json:"password"` |
|
87
|
} |
|
88
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|
89
|
writeError(w, http.StatusBadRequest, "invalid request body") |
|
90
|
return |
|
91
|
} |
|
92
|
|
|
93
|
if !s.admins.Authenticate(req.Username, req.Password) { |
|
94
|
writeError(w, http.StatusUnauthorized, "invalid credentials") |
|
95
|
return |
|
96
|
} |
|
97
|
|
|
98
|
// Create a session API key for this admin login. |
|
99
|
sessionName := "session:" + req.Username |
|
100
|
token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour)) |
|
101
|
if err != nil { |
|
102
|
s.log.Error("login: create session key", "err", err) |
|
103
|
writeError(w, http.StatusInternalServerError, "failed to create session") |
|
104
|
return |
|
105
|
} |
|
106
|
|
|
107
|
writeJSON(w, http.StatusOK, map[string]string{ |
|
108
|
"token": token, |
|
109
|
"username": req.Username, |
|
110
|
}) |
|
111
|
} |
|
112
|
|
|
113
|
// handleAdminList handles GET /v1/admins. |
|
114
|
func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) { |
|
115
|
admins := s.admins.List() |
|
116
|
type adminView struct { |
|
117
|
Username string `json:"username"` |
|
118
|
Created time.Time `json:"created"` |
|
119
|
} |
|
120
|
out := make([]adminView, len(admins)) |
|
121
|
for i, a := range admins { |
|
122
|
out[i] = adminView{Username: a.Username, Created: a.Created} |
|
123
|
} |
|
124
|
writeJSON(w, http.StatusOK, map[string]any{"admins": out}) |
|
125
|
} |
|
126
|
|
|
127
|
// handleAdminAdd handles POST /v1/admins. |
|
128
|
func (s *Server) handleAdminAdd(w http.ResponseWriter, r *http.Request) { |
|
129
|
var req struct { |
|
130
|
Username string `json:"username"` |
|
131
|
Password string `json:"password"` |
|
132
|
} |
|
133
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|
134
|
writeError(w, http.StatusBadRequest, "invalid request body") |
|
135
|
return |
|
136
|
} |
|
137
|
if req.Username == "" || req.Password == "" { |
|
138
|
writeError(w, http.StatusBadRequest, "username and password required") |
|
139
|
return |
|
140
|
} |
|
141
|
|
|
142
|
if err := s.admins.Add(req.Username, req.Password); err != nil { |
|
143
|
// Add returns an error if username already exists. |
|
144
|
writeError(w, http.StatusConflict, "username already exists") |
|
145
|
return |
|
146
|
} |
|
147
|
w.WriteHeader(http.StatusCreated) |
|
148
|
} |
|
149
|
|
|
150
|
// handleAdminRemove handles DELETE /v1/admins/{username}. |
|
151
|
func (s *Server) handleAdminRemove(w http.ResponseWriter, r *http.Request) { |
|
152
|
username := r.PathValue("username") |
|
153
|
if err := s.admins.Remove(username); err != nil { |
|
154
|
writeError(w, http.StatusNotFound, "admin not found") |
|
155
|
return |
|
156
|
} |
|
157
|
w.WriteHeader(http.StatusNoContent) |
|
158
|
} |
|
159
|
|
|
160
|
// handleAdminSetPassword handles PUT /v1/admins/{username}/password. |
|
161
|
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) { |
|
162
|
username := r.PathValue("username") |
|
163
|
|
|
164
|
var req struct { |
|
165
|
Password string `json:"password"` |
|
166
|
} |
|
167
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|
168
|
writeError(w, http.StatusBadRequest, "invalid request body") |
|
169
|
return |
|
170
|
} |
|
171
|
if req.Password == "" { |
|
172
|
writeError(w, http.StatusBadRequest, "password required") |
|
173
|
return |
|
174
|
} |
|
175
|
|
|
176
|
if err := s.admins.SetPassword(username, req.Password); err != nil { |
|
177
|
writeError(w, http.StatusNotFound, "admin not found") |
|
178
|
return |
|
179
|
} |
|
180
|
w.WriteHeader(http.StatusNoContent) |
|
181
|
} |
|
182
|
|