Pulse/internal/cloudcp/auth/handlers.go
2026-03-27 14:52:39 +00:00

496 lines
15 KiB
Go

package auth
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auditlog"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/email"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
const handoffTTL = 60 * time.Second
type adminGenerateMagicLinkRequest struct {
Email string `json:"email"`
TenantID string `json:"tenant_id"`
SendEmail bool `json:"send_email"`
}
type adminGenerateMagicLinkResponse struct {
URL string `json:"url"`
Email string `json:"email"`
TenantID string `json:"tenant_id"`
EmailSent bool `json:"email_sent"`
}
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// HandleMagicLinkVerify returns an http.HandlerFunc that validates a control-plane
// magic link token, generates a short-lived handoff token, and redirects the user
// to the tenant container.
func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDir, baseDomain, portalPath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
tokenStr := strings.TrimSpace(r.URL.Query().Get("token"))
if tokenStr == "" {
auditEvent(r, "cp_magic_link_verify", "failure").
Str("reason", "missing_token").
Msg("Magic link verification failed")
writeError(w, http.StatusBadRequest, "missing_token", "Token parameter is required")
return
}
token, err := svc.ValidateToken(tokenStr)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("reason", "invalid_or_expired_token").
Msg("Magic link verification failed")
// Browser redirect on failure.
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
http.Redirect(w, r, strings.TrimSpace(portalPath), http.StatusTemporaryRedirect)
return
}
writeError(w, http.StatusBadRequest, "invalid_token", "Invalid or expired magic link")
return
}
if token.Target == MagicLinkTargetPortal {
redirectPath := strings.TrimSpace(portalPath)
if redirectPath == "" {
redirectPath = "/portal"
}
userID, err := ensurePortalUserAndMembership(reg, token.TenantID, token.Email)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("tenant_id", token.TenantID).
Str("reason", "portal_session_identity_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
return
}
sessionVersion, err := reg.GetUserSessionVersion(userID)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("reason", "session_version_lookup_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
return
}
sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("reason", "session_issue_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
return
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
auditEvent(r, "cp_magic_link_verify", "success").
Str("tenant_id", token.TenantID).
Str("email", token.Email).
Str("target", string(token.Target)).
Msg("Magic link verified, redirecting to portal")
http.Redirect(w, r, redirectPath, http.StatusTemporaryRedirect)
return
}
// Look up tenant to confirm it exists and is active.
tenant, err := reg.Get(token.TenantID)
if err != nil || tenant == nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("tenant_id", token.TenantID).
Str("reason", "tenant_not_found").
Msg("Magic link verification failed")
writeError(w, http.StatusNotFound, "tenant_not_found", "Tenant not found")
return
}
// Read the per-tenant handoff key.
tenantDataDir := filepath.Join(tenantsDir, tenant.ID)
handoffKeyPath := filepath.Join(tenantDataDir, cloudauth.HandoffKeyFile)
handoffKey, err := os.ReadFile(handoffKeyPath)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("tenant_id", tenant.ID).
Str("reason", "handoff_key_read_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "handoff_error", "Unable to generate handoff")
return
}
userID, identityErr := ensureAccountUserAndMembership(reg, tenant, token.Email)
if identityErr != nil {
log.Warn().
Err(identityErr).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to establish control-plane session identity")
}
claims := cloudauth.Claims{
Email: token.Email,
TenantID: tenant.ID,
AccountID: strings.TrimSpace(tenant.AccountID),
UserID: strings.TrimSpace(userID),
Role: string(registry.MemberRoleOwner),
}
// Sign a short-lived handoff token.
handoffToken, err := cloudauth.SignWithClaims(handoffKey, claims, handoffTTL)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("tenant_id", tenant.ID).
Str("reason", "handoff_sign_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "handoff_error", "Unable to generate handoff")
return
}
// Build redirect URL: https://<tenant-id>.<baseDomain>/auth/cloud-handoff?token=Y
redirectURL := fmt.Sprintf("https://%s.%s/auth/cloud-handoff?token=%s",
tenant.ID, baseDomain, handoffToken)
if identityErr == nil {
if sessionVersion, err := reg.GetUserSessionVersion(userID); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Str("user_id", userID).
Msg("Failed to read user session version")
} else if sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to issue control-plane session")
} else {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
}
auditEvent(r, "cp_magic_link_verify", "success").
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Magic link verified, redirecting to tenant handoff")
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}
}
// HandleLogout revokes all existing sessions for the authenticated user and clears
// the session cookie.
func HandleLogout(reg *registry.TenantRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if reg == nil {
writeError(w, http.StatusServiceUnavailable, "registry_unavailable", "Logout unavailable")
return
}
userID := strings.TrimSpace(r.Header.Get("X-User-ID"))
if userID == "" {
writeError(w, http.StatusUnauthorized, "missing_user_identity", "Missing authenticated user")
return
}
newVersion, err := reg.RevokeUserSessions(userID)
if err != nil {
auditEvent(r, "cp_session_logout", "failure").
Err(err).
Str("user_id", userID).
Msg("Session logout failed")
writeError(w, http.StatusInternalServerError, "logout_failed", "Failed to revoke session")
return
}
clearSessionCookie(w)
auditEvent(r, "cp_session_logout", "success").
Str("user_id", userID).
Int64("new_session_version", newVersion).
Msg("Session revoked via logout")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"ok": true,
}); err != nil {
log.Error().Err(err).Msg("cloudcp.auth: encode logout response")
}
}
}
// HandleAdminGenerateMagicLink returns an admin-only handler that generates a magic
// link for a given email + tenant_id. The caller is responsible for wrapping this
// with AdminKeyMiddleware.
//
// If send_email is true in the request, the magic link is also emailed to the user.
func HandleAdminGenerateMagicLink(svc *Service, baseURL string, emailSender email.Sender, emailFrom string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
var req adminGenerateMagicLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
auditEvent(r, "cp_magic_link_admin_generate", "failure").
Str("reason", "bad_request").
Msg("Admin magic link generation failed")
writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body")
return
}
if req.Email == "" || req.TenantID == "" {
auditEvent(r, "cp_magic_link_admin_generate", "failure").
Str("reason", "missing_email_or_tenant_id").
Msg("Admin magic link generation failed")
writeError(w, http.StatusBadRequest, "bad_request", "email and tenant_id are required")
return
}
token, err := svc.GenerateToken(req.Email, req.TenantID)
if err != nil {
auditEvent(r, "cp_magic_link_admin_generate", "failure").
Err(err).
Str("email", req.Email).
Str("tenant_id", req.TenantID).
Str("reason", "token_generation_failed").
Msg("Admin magic link generation failed")
writeError(w, http.StatusInternalServerError, "generate_error", "Failed to generate magic link")
return
}
magicURL := BuildVerifyURL(baseURL, token)
emailSent := false
if req.SendEmail && emailSender != nil && emailFrom != "" {
html, text, renderErr := email.RenderMagicLinkEmail(email.MagicLinkData{MagicLinkURL: magicURL})
if renderErr != nil {
log.Error().
Err(renderErr).
Str("tenant_id", req.TenantID).
Str("email", req.Email).
Msg("Failed to render magic link email")
} else if sendErr := emailSender.Send(r.Context(), email.Message{
From: emailFrom,
To: req.Email,
Subject: "Sign in to Pulse",
HTML: html,
Text: text,
}); sendErr != nil {
log.Error().
Err(sendErr).
Str("tenant_id", req.TenantID).
Str("email", req.Email).
Msg("Failed to send magic link email")
} else {
emailSent = true
}
}
auditEvent(r, "cp_magic_link_admin_generate", "success").
Str("email", req.Email).
Str("tenant_id", req.TenantID).
Bool("email_sent", emailSent).
Msg("Admin generated magic link")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"url": magicURL,
"email": req.Email,
"tenant_id": req.TenantID,
"email_sent": emailSent,
}); err != nil {
log.Error().Err(err).Msg("cloudcp.auth: encode admin magic link response")
}
}
}
func auditEvent(r *http.Request, eventName, outcome string) *zerolog.Event {
e := log.Info()
if outcome != "success" {
e = log.Warn()
}
actorID := auditlog.ActorID(r)
if actorID == "" {
actorID = "admin_key"
}
return e.
Str("audit_event", eventName).
Str("outcome", outcome).
Str("actor_id", actorID).
Str("client_ip", auditlog.ClientIP(r)).
Str("method", r.Method).
Str("path", auditlog.RequestPath(r))
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]string{
"error": code,
"message": message,
}); err != nil {
log.Error().Err(err).Msg("cloudcp.auth: encode error response")
}
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0).UTC(),
})
}
func ensurePortalUserAndMembership(reg *registry.TenantRegistry, tenantID, email string) (string, error) {
if reg == nil {
return "", fmt.Errorf("registry unavailable")
}
email = strings.ToLower(strings.TrimSpace(email))
tenantID = strings.TrimSpace(tenantID)
if tenantID != "" {
tenant, err := reg.Get(tenantID)
if err != nil {
return "", fmt.Errorf("lookup tenant: %w", err)
}
if tenant != nil {
return ensureAccountUserAndMembership(reg, tenant, email)
}
}
return ensurePortalUser(reg, email)
}
func ensurePortalUser(reg *registry.TenantRegistry, email string) (string, error) {
if reg == nil {
return "", fmt.Errorf("registry unavailable")
}
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", fmt.Errorf("email is required")
}
user, err := reg.GetUserByEmail(email)
if err != nil {
return "", fmt.Errorf("lookup user by email: %w", err)
}
if user == nil {
userID, genErr := registry.GenerateUserID()
if genErr != nil {
return "", fmt.Errorf("generate user id: %w", genErr)
}
candidate := &registry.User{
ID: userID,
Email: email,
}
if createErr := reg.CreateUser(candidate); createErr != nil {
reloaded, reloadErr := reg.GetUserByEmail(email)
if reloadErr != nil || reloaded == nil {
return "", fmt.Errorf("create user: %w", createErr)
}
user = reloaded
} else {
user = candidate
}
}
if user == nil || strings.TrimSpace(user.ID) == "" {
return "", fmt.Errorf("user resolution failed")
}
_ = reg.UpdateUserLastLogin(user.ID)
return user.ID, nil
}
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) {
if reg == nil {
return "", fmt.Errorf("registry unavailable")
}
if tenant == nil {
return "", fmt.Errorf("tenant is required")
}
accountID := strings.TrimSpace(tenant.AccountID)
if accountID == "" {
return "", fmt.Errorf("tenant has no account id")
}
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", fmt.Errorf("email is required")
}
userID, err := ensurePortalUser(reg, email)
if err != nil {
return "", err
}
user := &registry.User{ID: userID}
m, err := reg.GetMembership(accountID, user.ID)
if err != nil {
return "", fmt.Errorf("lookup membership: %w", err)
}
if m == nil {
newMembership := &registry.AccountMembership{
AccountID: accountID,
UserID: user.ID,
Role: registry.MemberRoleOwner,
}
if createErr := reg.CreateMembership(newMembership); createErr != nil {
reloaded, reloadErr := reg.GetMembership(accountID, user.ID)
if reloadErr != nil || reloaded == nil {
return "", fmt.Errorf("create membership: %w", createErr)
}
}
}
return user.ID, nil
}