Files
Anand 4dfeb9848e Wave 2: real-time events/webhooks, cross-remote tags/search/bulk-ops, cert & session monitoring
- internal/events: in-process pub/sub bus; GET /api/v1/events SSE stream
- internal/notify/webhooks.go: signed outgoing webhook dispatcher w/ retry + delivery log
- internal/api/tags.go, search.go, bulk.go: cross-remote tag aggregation, global search, fleet-wide bulk guest actions
- internal/auth/sessions.go: session listing/revocation (self-service + admin), extends the existing stateful session table
- internal/poller/certificates.go: cert-expiry + connection-staleness alerting via the existing alert_rules pipeline
- web: useEventStream hook, GlobalSearch + BulkOperationsPage (not yet wired into nav), SessionsCard on ProfilePage
2026-09-10 11:49:11 +05:30

198 lines
6.9 KiB
Go

package api
import (
"encoding/json"
"errors"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"ferrum/internal/auth"
)
func (s *Server) authSetupStatus(w http.ResponseWriter, r *http.Request) {
needsSetup, err := s.auth.NeedsSetup(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needsSetup": needsSetup})
}
type setupRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
func (s *Server) authSetup(w http.ResponseWriter, r *http.Request) {
var req setupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if len(req.Password) < 8 {
writeErrorMsg(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if req.Username == "" || req.Email == "" {
writeErrorMsg(w, http.StatusBadRequest, "username and email are required")
return
}
if _, err := s.auth.Bootstrap(r.Context(), req.Username, req.Email, req.Password); err != nil {
if errors.Is(err, auth.ErrAlreadyBootstrapped) {
writeErrorMsg(w, http.StatusConflict, "setup has already been completed")
return
}
s.writeError(w, http.StatusInternalServerError, err)
return
}
user, token, err := s.auth.Login(r.Context(), req.Username, req.Password, r.RemoteAddr, r.UserAgent())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
slog.Info("setup completed, first admin created", "username", user.Username)
s.auditEntry(r.Context(), user.ID, "auth.setup", "auth", user.Username, r.RemoteAddr)
auth.SetSessionCookie(w, token, s.cookieSecure(r), s.auth.SessionTTL())
writeJSON(w, http.StatusCreated, user)
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
limiterKey := r.RemoteAddr + "|" + strings.ToLower(req.Username)
if allowed, retryAfter := s.logins.Allowed(limiterKey); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
slog.Warn("login rate-limited", "remote", r.RemoteAddr)
writeErrorMsg(w, http.StatusTooManyRequests, "too many failed attempts — try again later")
return
}
user, err := s.auth.VerifyPassword(r.Context(), req.Username, req.Password)
if err != nil {
s.logins.RecordFailure(limiterKey)
slog.Warn("login failed", "remote", r.RemoteAddr)
s.auditEntry(r.Context(), "", "auth.login.failed", "auth", req.Username, r.RemoteAddr)
writeErrorMsg(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.logins.RecordSuccess(limiterKey)
if user.TOTPEnabled {
pendingToken := newPendingLogin(user.ID)
writeJSON(w, http.StatusOK, map[string]string{"requiresTotp": "true", "pendingToken": pendingToken})
return
}
token, err := s.auth.CreateSession(r.Context(), user.ID, r.RemoteAddr, r.UserAgent())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
slog.Info("login succeeded", "username", user.Username)
s.auditEntry(r.Context(), user.ID, "auth.login", "auth", user.Username, r.RemoteAddr)
auth.SetSessionCookie(w, token, s.cookieSecure(r), s.auth.SessionTTL())
writeJSON(w, http.StatusOK, user)
}
type loginTOTPRequest struct {
PendingToken string `json:"pendingToken"`
Code string `json:"code"`
}
// authLoginTOTP completes the two-step login started by authLogin once the
// caller has proven possession of the user's TOTP device (or a recovery code).
func (s *Server) authLoginTOTP(w http.ResponseWriter, r *http.Request) {
var req loginTOTPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
userID, ok := consumePendingLogin(req.PendingToken)
if !ok {
writeErrorMsg(w, http.StatusUnauthorized, "login session expired — sign in again")
return
}
// The pending token is single-use, so each attempt already costs a full
// password login; this limiter is defense in depth for the same key.
totpKey := "totp|" + userID
if allowed, retryAfter := s.logins.Allowed(totpKey); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
writeErrorMsg(w, http.StatusTooManyRequests, "too many failed attempts — try again later")
return
}
if err := s.auth.VerifyTOTPStep(r.Context(), userID, req.Code); err != nil {
s.logins.RecordFailure(totpKey)
slog.Warn("totp verification failed", "userId", userID, "remote", r.RemoteAddr)
s.auditEntry(r.Context(), userID, "auth.login.totp_failed", "auth", userID, r.RemoteAddr)
writeErrorMsg(w, http.StatusUnauthorized, "invalid authentication code")
return
}
s.logins.RecordSuccess(totpKey)
token, err := s.auth.CreateSession(r.Context(), userID, r.RemoteAddr, r.UserAgent())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
user, err := s.auth.Authenticate(r.Context(), token)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
slog.Info("login succeeded (totp)", "username", user.Username)
s.auditEntry(r.Context(), user.ID, "auth.login", "auth", user.Username+" (totp)", r.RemoteAddr)
auth.SetSessionCookie(w, token, s.cookieSecure(r), s.auth.SessionTTL())
writeJSON(w, http.StatusOK, user)
}
// authLogout revokes the Ferrum session and, only when the admin has opted
// in (Settings → SSO → "Also sign out at the identity provider") and the
// provider supports RP-Initiated Logout, tells the frontend where to send
// the browser next to also end the session at the identity provider (SLO).
// Opt-in, not automatic: the provider must have Ferrum's post-logout
// redirect URL registered first (e.g. Keycloak's "Valid post logout
// redirect URIs") or it rejects the redirect with invalid_redirect_uri —
// enabling this unconditionally for every existing SSO deployment would
// have broken sign-out for anyone who hadn't done that yet.
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
var idToken string
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
idToken, _ = s.auth.Logout(r.Context(), cookie.Value)
}
if u := userFromContext(r); u != nil {
slog.Info("logout", "username", u.Username)
s.audit(r, "auth.logout", "auth", u.Username)
}
auth.ClearSessionCookie(w, s.cookieSecure(r))
if idToken != "" {
if oidc := s.getOIDC(); oidc != nil && oidc.SingleLogoutEnabled() {
if endSessionURL, ok := oidc.EndSessionURL(idToken); ok {
writeJSON(w, http.StatusOK, map[string]string{"logoutUrl": endSessionURL})
return
}
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) authMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, userFromContext(r))
}