ScuttleBot

scuttlebot / internal / api / login.go
Blame History Raw 182 lines
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

Keyboard Shortcuts

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