mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
134 lines
4.2 KiB
Go
134 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// HandleCloudHandoff returns an HTTP handler that completes the control-plane → tenant
|
|
// auth handoff. It reads a per-tenant HMAC key, verifies the handoff token, creates a
|
|
// session, and redirects to the dashboard.
|
|
//
|
|
// Self-guards: returns 404 if the handoff key file does not exist in dataPath,
|
|
// meaning this is not a cloud-managed tenant.
|
|
func HandleCloudHandoff(dataPath string) http.HandlerFunc {
|
|
InitPersistentAuthStores(dataPath)
|
|
replay := &jtiReplayStore{configDir: dataPath}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Self-guard: only respond if a handoff key exists.
|
|
keyPath := filepath.Join(dataPath, cloudauth.HandoffKeyFile)
|
|
handoffKey, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
tokenStr := strings.TrimSpace(r.URL.Query().Get("token"))
|
|
if tokenStr == "" {
|
|
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
claims, err := cloudauth.VerifyClaimsWithExpiry(handoffKey, tokenStr)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Cloud handoff token verification failed")
|
|
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
email := normalizeHandoffEmail(claims.Email)
|
|
tenantID := strings.TrimSpace(claims.TenantID)
|
|
if email == "" || !isValidOrganizationID(tenantID) {
|
|
log.Warn().Str("tenant_id", tenantID).Msg("Cloud handoff token rejected due to invalid tenant ID")
|
|
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if err := ensureHandoffOrganizationMembership(dataPath, tenantID, email, claims.Role); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("tenant_id", tenantID).
|
|
Str("email", email).
|
|
Msg("Cloud handoff membership repair failed")
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tokenHash := sha256.Sum256([]byte(tokenStr))
|
|
replayID := "handoff:" + hex.EncodeToString(tokenHash[:])
|
|
stored, storeErr := replay.checkAndStore(replayID, claims.ExpiresAt)
|
|
if storeErr != nil {
|
|
log.Error().Err(storeErr).Msg("Cloud handoff replay-store failure")
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !stored {
|
|
log.Warn().Str("replay_id_prefix", replayID[:24]).Msg("Cloud handoff token replay blocked")
|
|
http.Redirect(w, r, "/login?error=handoff_replayed", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Invalidate any pre-existing session to prevent session fixation attacks.
|
|
InvalidateOldSessionFromRequest(r)
|
|
|
|
// Create session using existing machinery (same pattern as HandlePublicMagicLinkVerify).
|
|
sessionToken := generateSessionToken()
|
|
if sessionToken == "" {
|
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
userAgent := r.Header.Get("User-Agent")
|
|
clientIP := GetClientIP(r)
|
|
sessionDuration := 24 * time.Hour
|
|
GetSessionStore().CreateSession(sessionToken, sessionDuration, userAgent, clientIP, email)
|
|
TrackUserSession(email, sessionToken)
|
|
|
|
csrfToken := generateCSRFToken(sessionToken)
|
|
isSecure, sameSitePolicy := getCookieSettings(r)
|
|
cookieMaxAge := int(sessionDuration.Seconds())
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(isSecure),
|
|
Value: sessionToken,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: cookieMaxAge,
|
|
})
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieNameCSRF,
|
|
Value: csrfToken,
|
|
Path: "/",
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: cookieMaxAge,
|
|
})
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieNameOrgID,
|
|
Value: tenantID,
|
|
Path: "/",
|
|
Secure: isSecure,
|
|
SameSite: sameSitePolicy,
|
|
MaxAge: cookieMaxAge,
|
|
})
|
|
|
|
log.Info().
|
|
Str("email", email).
|
|
Msg("Cloud handoff completed, session created")
|
|
|
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
|
}
|
|
}
|