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 }