mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
496 lines
15 KiB
Go
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 := ®istry.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 := ®istry.User{ID: userID}
|
|
|
|
m, err := reg.GetMembership(accountID, user.ID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lookup membership: %w", err)
|
|
}
|
|
if m == nil {
|
|
newMembership := ®istry.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
|
|
}
|