Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
9.1 KiB
Go
282 lines
9.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/model"
|
|
"steward/internal/store"
|
|
"steward/internal/version"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "steward_session"
|
|
csrfHeader = "X-CSRF-Token"
|
|
)
|
|
|
|
// rateLimiter limits failed logins and token attempts per key (address or
|
|
// user name) over a sliding window.
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
hits map[string][]time.Time
|
|
}
|
|
|
|
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
|
|
|
|
const (
|
|
failWindow = 15 * time.Minute
|
|
failsPerIP = 20
|
|
failsPerKey = 8
|
|
)
|
|
|
|
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
|
|
var keep []time.Time
|
|
for _, t := range l.hits[key] {
|
|
if now.Sub(t) < failWindow {
|
|
keep = append(keep, t)
|
|
}
|
|
}
|
|
l.hits[key] = keep
|
|
return keep
|
|
}
|
|
|
|
func (l *rateLimiter) blocked(key string, max int) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return len(l.recent(key, time.Now())) >= max
|
|
}
|
|
|
|
func (l *rateLimiter) fail(keys ...string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := time.Now()
|
|
for _, k := range keys {
|
|
l.hits[k] = append(l.recent(k, now), now)
|
|
}
|
|
}
|
|
|
|
func (l *rateLimiter) reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.hits, key)
|
|
}
|
|
|
|
func (l *rateLimiter) cleanup() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := time.Now()
|
|
for k := range l.hits {
|
|
if len(l.recent(k, now)) == 0 {
|
|
delete(l.hits, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer steward_...'"}
|
|
|
|
// authenticate identifies the caller: the local socket's peer, a bearer
|
|
// token or a session cookie (with the CSRF token for changes).
|
|
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
|
|
ip := s.clientIP(r)
|
|
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
|
|
if trustedUID(pr.UID) {
|
|
return socketPrincipal(pr, r.UserAgent()), nil, nil
|
|
}
|
|
return nil, nil, socketDenied(pr)
|
|
}
|
|
if h := r.Header.Get("Authorization"); h != "" {
|
|
scheme, tok, _ := strings.Cut(h, " ")
|
|
if !strings.EqualFold(scheme, "Bearer") {
|
|
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
|
|
}
|
|
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
|
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
|
|
}
|
|
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
|
if err != nil {
|
|
s.limiter.fail("ip:" + ip)
|
|
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
|
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
|
}
|
|
return p, nil, nil
|
|
}
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
return nil, nil, errUnauthenticated
|
|
}
|
|
hash := auth.HashSecret(c.Value)
|
|
se, err := s.store.GetSession(r.Context(), hash)
|
|
if err != nil {
|
|
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
|
}
|
|
now := time.Now()
|
|
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
|
|
s.store.DeleteSession(r.Context(), hash)
|
|
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
|
}
|
|
u, err := s.store.GetUser(r.Context(), se.UserID)
|
|
if err != nil || u.Disabled {
|
|
s.store.DeleteSession(r.Context(), hash)
|
|
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
got := r.Header.Get(csrfHeader)
|
|
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
|
|
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
|
|
}
|
|
}
|
|
if now.Sub(se.LastSeenAt) > time.Minute {
|
|
s.store.TouchSession(r.Context(), hash, now)
|
|
}
|
|
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
|
|
}
|
|
|
|
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
|
|
pid, secret, err := auth.ParseToken(tok)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t, err := s.store.GetTokenByPublicID(ctx, pid)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, errors.New("unknown token")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !auth.CheckSecret(secret, t.SecretHash) {
|
|
return nil, errors.New("wrong token secret")
|
|
}
|
|
if t.RevokedAt != nil {
|
|
return nil, errors.New("the token was revoked")
|
|
}
|
|
now := time.Now()
|
|
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
|
|
return nil, errors.New("the token has expired")
|
|
}
|
|
u, err := s.store.GetUser(ctx, t.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.Disabled {
|
|
return nil, errors.New("the token's user is disabled")
|
|
}
|
|
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
|
s.store.TouchToken(ctx, t.ID, ip)
|
|
}
|
|
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
|
}
|
|
|
|
// authed wraps a handler with authentication and error handling.
|
|
func (s *Server) authed(h handler) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
|
|
p, se, err := s.authenticate(r)
|
|
if err != nil {
|
|
s.writeErr(w, r, err, nil)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), keyPrincipal, p)
|
|
if se != nil {
|
|
ctx = context.WithValue(ctx, keySession, se)
|
|
}
|
|
r = r.WithContext(ctx)
|
|
if err := h(w, r, p); err != nil {
|
|
s.writeErr(w, r, err, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// public wraps an unauthenticated handler.
|
|
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
if err := h(w, r); err != nil {
|
|
s.writeErr(w, r, err, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) cookieSecure(r *http.Request) bool {
|
|
switch s.cfg.CookieSecure {
|
|
case "always":
|
|
return true
|
|
case "never":
|
|
return false
|
|
}
|
|
return s.isHTTPS(r)
|
|
}
|
|
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
|
var in api.Login
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
ip := s.clientIP(r)
|
|
name := strings.TrimSpace(in.Username)
|
|
userKey := "user:" + strings.ToLower(name)
|
|
who := &auth.Principal{User: &model.User{Username: name}, IP: ip, Method: "session"}
|
|
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
|
|
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user "+name, "denied", "too many failed attempts")
|
|
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
|
|
}
|
|
u, err := s.store.GetUserByName(r.Context(), name)
|
|
hash := ""
|
|
if err == nil {
|
|
hash = u.PasswordHash
|
|
}
|
|
ok := auth.CheckPassword(hash, in.Password)
|
|
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
|
|
s.limiter.fail("ip:"+ip, userKey)
|
|
reason := "wrong username or password"
|
|
if err == nil && u.Disabled && ok {
|
|
reason = "account disabled"
|
|
}
|
|
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
|
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
|
}
|
|
s.limiter.reset(userKey)
|
|
id, csrf := auth.NewSessionID()
|
|
now := time.Now()
|
|
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
|
|
if err := s.store.CreateSession(r.Context(), se); err != nil {
|
|
return err
|
|
}
|
|
s.store.TouchLogin(r.Context(), u.ID)
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
|
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
|
s.eng.Audit(r.Context(), p, "auth.login", "user "+u.Username, "success", "")
|
|
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
|
s.store.DeleteSession(r.Context(), se.IDHash)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
|
s.eng.Audit(r.Context(), p, "auth.logout", "user "+p.Name(), "success", "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
|
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
|
|
if se != nil {
|
|
out.CSRFToken = se.CSRFToken
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
se, _ := r.Context().Value(keySession).(*model.Session)
|
|
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
|
return nil
|
|
}
|