mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-16 15:45:09 +00:00
d9b2519b75
- New PBS Backups page (/pbs): datastore usage, group browser, per-group prune, GC with live status, sync/verify jobs with admin-gated run - Shared single-connection SSE store (lib/sse.ts) feeding NotificationBell and the telemetry pill; global offline banner with reconnect invalidation - Correctness: CIFS storage credentials sent in JSON body; per-row alert silencing + unsilence endpoint; graceful stop-and-delete for running guests; accent/shadow token fixes for all look presets - Safer operations: confirms for SDN apply and host upgrade-all; Refresh control on every page; 30s polling added to previously-static pages - Wayfinding: guests deep-linkable via /inventory?focusGuest, scoped inventory views, topology guest click-through, Overview links - Forms/consistency: node storage/bridge pick-lists in CreateGuestDialog, backup schedule validation, BulkOperationsPage on shared DataTable, progressive pagination on cluster access lists, shared Timestamp and chartToneFor helpers, editable alert rules and webhooks - Security/tests: CSRF, authz-matrix, redaction and PBS test coverage; webhook outbox + connection TLS fingerprint migrations (00032/00033, sqlite + postgres) - Comment audit: removed stale, duplicated and orphaned comments; corrected inaccurate doc comments (UpdateGuestConfig, InvalidateAll, breakpoint references); no behavior changes
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"ferrum/internal/auth"
|
|
)
|
|
|
|
// oidcPending bridges the redirect to the provider and the callback: it
|
|
// proves the callback belongs to a login we actually started (state) and
|
|
// that the returned ID token was minted for this exact login (nonce),
|
|
// mirroring the pendingLogin pattern used for the TOTP step-up in totp.go.
|
|
type oidcPending struct {
|
|
nonce string
|
|
expires time.Time
|
|
}
|
|
|
|
var (
|
|
oidcPendingMu sync.Mutex
|
|
oidcPendingM = map[string]oidcPending{}
|
|
)
|
|
|
|
func newOIDCState() (state, nonce string) {
|
|
state, nonce = randomHex(16), randomHex(16)
|
|
now := time.Now()
|
|
oidcPendingMu.Lock()
|
|
for id, p := range oidcPendingM {
|
|
if now.After(p.expires.Add(10 * time.Minute)) {
|
|
delete(oidcPendingM, id)
|
|
}
|
|
}
|
|
oidcPendingM[state] = oidcPending{nonce: nonce, expires: now.Add(5 * time.Minute)}
|
|
oidcPendingMu.Unlock()
|
|
return state, nonce
|
|
}
|
|
|
|
func consumeOIDCState(state string) (nonce string, ok bool) {
|
|
oidcPendingMu.Lock()
|
|
defer oidcPendingMu.Unlock()
|
|
p, found := oidcPendingM[state]
|
|
delete(oidcPendingM, state)
|
|
if !found || time.Now().After(p.expires) {
|
|
return "", false
|
|
}
|
|
return p.nonce, true
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// oidcStateCookie binds the /oidc/callback round-trip to the browser that
|
|
// started it: the state is planted in this cookie at /oidc/login and must
|
|
// come back with the callback's query state, so one user's login redirect
|
|
// can't be replayed against another's session (CSRF on the callback). The
|
|
// server-side state map remains the nonce store — the cookie is only the
|
|
// "same browser that started this" check.
|
|
const oidcStateCookie = "ferrum_oidc_state"
|
|
|
|
// oidcStateCookiePath scopes the state cookie to the OIDC route tree it
|
|
// belongs to; it's also the Path both set and clear must use to match.
|
|
const oidcStateCookiePath = "/api/v1/auth/oidc"
|
|
|
|
func (s *Server) setOIDCStateCookie(w http.ResponseWriter, r *http.Request, state string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: oidcStateCookie,
|
|
Value: state,
|
|
Path: oidcStateCookiePath,
|
|
HttpOnly: true,
|
|
Secure: s.cookieSecure(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 600, // outlives the server-side state's 5-minute TTL slightly
|
|
})
|
|
}
|
|
|
|
func (s *Server) clearOIDCStateCookie(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: oidcStateCookie,
|
|
Value: "",
|
|
Path: oidcStateCookiePath,
|
|
HttpOnly: true,
|
|
Secure: s.cookieSecure(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
// oidcConfig tells the frontend whether to show an SSO button, without
|
|
// exposing any secret — the login page needs this before the user has
|
|
// authenticated, so it's intentionally not behind requireAuth.
|
|
func (s *Server) oidcConfig(w http.ResponseWriter, r *http.Request) {
|
|
oidc := s.getOIDC()
|
|
if oidc == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "displayName": oidc.DisplayName()})
|
|
}
|
|
|
|
func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
|
|
oidc := s.getOIDC()
|
|
if oidc == nil {
|
|
writeErrorMsg(w, http.StatusNotFound, "SSO is not configured")
|
|
return
|
|
}
|
|
state, nonce := newOIDCState()
|
|
authURL, err := oidc.AuthURL(state, nonce)
|
|
if err != nil {
|
|
slog.Error("building OIDC auth URL", "error", err)
|
|
writeErrorMsg(w, http.StatusBadGateway, "could not reach the SSO provider")
|
|
return
|
|
}
|
|
s.setOIDCStateCookie(w, r, state)
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// oidcCallback completes the flow: verify state/nonce, exchange the code,
|
|
// verify the ID token, provision/link the local user, and start a normal
|
|
// Ferrum session exactly as password login would.
|
|
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|
oidc := s.getOIDC()
|
|
if oidc == nil {
|
|
writeErrorMsg(w, http.StatusNotFound, "SSO is not configured")
|
|
return
|
|
}
|
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
|
slog.Warn("OIDC provider returned an error", "error", errParam, "description", r.URL.Query().Get("error_description"))
|
|
http.Redirect(w, r, "/login?sso_error=1", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
state := r.URL.Query().Get("state")
|
|
cookieState, cookieErr := r.Cookie(oidcStateCookie)
|
|
nonce, ok := consumeOIDCState(state)
|
|
// The callback must be the same browser that started the login: the
|
|
// state cookie has to be present and carry exactly the query state.
|
|
// Both sides are consumed either way so a replayed URL never validates.
|
|
if !ok || cookieErr != nil || cookieState.Value != state {
|
|
s.clearOIDCStateCookie(w, r)
|
|
slog.Warn("OIDC callback with unknown, expired, or mismatched state")
|
|
http.Redirect(w, r, "/login?sso_error=1", http.StatusFound)
|
|
return
|
|
}
|
|
// The state has served its purpose — drop the cookie before continuing,
|
|
// so it's gone regardless of how the rest of the flow ends.
|
|
s.clearOIDCStateCookie(w, r)
|
|
|
|
code := r.URL.Query().Get("code")
|
|
claims, err := oidc.Exchange(code, nonce)
|
|
if err != nil {
|
|
slog.Error("OIDC token exchange/verification failed", "error", err)
|
|
http.Redirect(w, r, "/login?sso_error=1", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
user, err := s.auth.FindOrCreateOIDCUser(r.Context(), claims.Subject, claims.Email, claims.PreferredUsername, claims.EmailVerified, oidc.AllowAutoProvision())
|
|
if errors.Is(err, auth.ErrOIDCUserNotProvisioned) {
|
|
slog.Warn("OIDC login refused: no matching local account and auto-provisioning is disabled", "subject", claims.Subject, "email", claims.Email)
|
|
http.Redirect(w, r, "/login?sso_error=no_account", http.StatusFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Error("provisioning OIDC user", "error", err)
|
|
http.Redirect(w, r, "/login?sso_error=1", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
token, err := s.auth.CreateOIDCSession(r.Context(), user.ID, claims.RawIDToken, r.RemoteAddr, r.UserAgent())
|
|
if err != nil {
|
|
slog.Error("creating session for OIDC user", "error", err)
|
|
http.Redirect(w, r, "/login?sso_error=1", http.StatusFound)
|
|
return
|
|
}
|
|
slog.Info("login succeeded (sso)", "username", user.Username)
|
|
auth.SetSessionCookie(w, token, s.cookieSecure(r), s.auth.SessionTTL())
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|