ScuttleBot

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

Keyboard Shortcuts

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