mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
0e40ec07cb
Exercised the full provider portal as a pilot MSP would and fixed what made it feel broken: - The signed-out portal promised "a sign-in link is on the way" even when the control plane has no email provider (the bundle default), and team invitations silently sent nothing. The portal bootstrap now carries email_sign_in_available and provider_hosted_mode; the sign-in page shows the host command that actually prints a link, and the invite panel says invitation emails are not sent and how to hand over a link instead. - New "provider-msp portal-link --email" CLI mints a one-time portal link for an account member or pending invitee, so teammates can sign in at all on email-less installs (bootstrap only covers the owner). - Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them and provider-hosted MSP mode defaults to 7 days. - Creating a client past the license cap showed a generic "Failed to create workspace." toast: the limit error is now a JSON payload with current/limit, the API client no longer drops non-JSON error bodies (double body read), and the toast explains the license limit. - Copy polish: provider-mode sign-in intro (no refunds/privacy register), least-privilege default invite role, queue tile label matches "Client onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh summary now prints the bootstrap next step and day-2 sign-in commands, .env.example and docs/MSP.md document portal sign-in and sessions. Contracts updated (cloud-paid, api-contracts, deployment-installability, security-privacy) with verification pins in tenant_handlers_test, config_test, magiclink_test, and provider_msp_deploy_test. Verified live against a dockerless control plane: portal-link for an invitee redeems, promotes the invitation, and sets a 7-day session; the at-cap toast shows the license copy; portal vitest suite and cloudcp/auth/account/installtests Go suites pass.
231 lines
6.1 KiB
Go
231 lines
6.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
magicLinkTTL = 15 * time.Minute
|
|
magicLinkPrefix = "ml1_"
|
|
hmacKeyFile = ".cp_magic_link_key"
|
|
hmacKeySize = 32
|
|
maxHMACKeyFileSize = 64
|
|
)
|
|
|
|
type MagicLinkTarget string
|
|
|
|
const (
|
|
MagicLinkTargetTenant MagicLinkTarget = "tenant"
|
|
MagicLinkTargetPortal MagicLinkTarget = "portal"
|
|
)
|
|
|
|
// Token holds the validated data from a consumed magic link token.
|
|
type Token struct {
|
|
Email string
|
|
TenantID string
|
|
Target MagicLinkTarget
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// Service manages magic link token generation and validation for the control plane.
|
|
// It does NOT import internal/api — it is a standalone reimplementation using its own SQLite store.
|
|
type Service struct {
|
|
hmacKey []byte
|
|
store *Store
|
|
ttl time.Duration
|
|
sessionTTL time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewService creates a Service backed by a SQLite store in cpDataDir.
|
|
// It loads (or generates) an HMAC key from {cpDataDir}/.cp_magic_link_key.
|
|
func NewService(cpDataDir string) (*Service, error) {
|
|
normalizedDir, err := securityutil.NormalizeStorageDir(cpDataDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cp data dir is required: %w", err)
|
|
}
|
|
if err := ensureOwnerOnlyDir(normalizedDir); err != nil {
|
|
return nil, fmt.Errorf("ensure cp data dir: %w", err)
|
|
}
|
|
|
|
keyPath, err := securityutil.JoinStorageLeaf(normalizedDir, hmacKeyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve magic link hmac key path: %w", err)
|
|
}
|
|
|
|
key, err := loadOrGenerateKey(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("magic link hmac key: %w", err)
|
|
}
|
|
|
|
store, err := NewStore(normalizedDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("magic link store: %w", err)
|
|
}
|
|
|
|
return &Service{
|
|
hmacKey: key,
|
|
store: store,
|
|
ttl: magicLinkTTL,
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// GenerateToken creates a new magic link token for the given email and tenant.
|
|
// Returns a string in the format "ml1_<random>" that can be included in a URL.
|
|
func (s *Service) GenerateToken(email, tenantID string) (string, error) {
|
|
return s.generateTokenForTarget(email, tenantID, MagicLinkTargetTenant)
|
|
}
|
|
|
|
// GeneratePortalToken creates a new portal-targeted magic link token. tenantID
|
|
// is optional and, when present, is used during verification to ensure the
|
|
// control-plane membership exists before the session is established.
|
|
func (s *Service) GeneratePortalToken(email, tenantID string) (string, error) {
|
|
return s.generateTokenForTarget(email, tenantID, MagicLinkTargetPortal)
|
|
}
|
|
|
|
func (s *Service) generateTokenForTarget(email, tenantID string, target MagicLinkTarget) (string, error) {
|
|
if s == nil {
|
|
return "", fmt.Errorf("magic link service not configured")
|
|
}
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
tenantID = strings.TrimSpace(tenantID)
|
|
if email == "" {
|
|
return "", fmt.Errorf("email is required")
|
|
}
|
|
switch target {
|
|
case MagicLinkTargetTenant:
|
|
if tenantID == "" {
|
|
return "", fmt.Errorf("tenantID is required")
|
|
}
|
|
case MagicLinkTargetPortal:
|
|
// tenantID is optional for portal-targeted sign-in.
|
|
default:
|
|
return "", fmt.Errorf("unsupported magic link target %q", target)
|
|
}
|
|
|
|
expiresAt := s.now().UTC().Add(s.ttl)
|
|
expiresAt = time.Unix(expiresAt.Unix(), 0).UTC()
|
|
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tokenHash := signHMAC(s.hmacKey, token)
|
|
if err := s.store.Put(tokenHash, &TokenRecord{
|
|
Email: email,
|
|
TenantID: tenantID,
|
|
Target: string(target),
|
|
ExpiresAt: expiresAt,
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// ValidateToken atomically consumes a token and returns the associated data.
|
|
// The token can only be used once.
|
|
func (s *Service) ValidateToken(token string) (*Token, error) {
|
|
if s == nil {
|
|
return nil, ErrTokenInvalid
|
|
}
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, ErrTokenInvalid
|
|
}
|
|
|
|
tokenHash := signHMAC(s.hmacKey, token)
|
|
rec, err := s.store.Consume(tokenHash, s.now().UTC())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Token{
|
|
Email: rec.Email,
|
|
TenantID: rec.TenantID,
|
|
Target: MagicLinkTarget(rec.Target),
|
|
ExpiresAt: rec.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// BuildVerifyURL constructs the full magic link verification URL.
|
|
func BuildVerifyURL(baseURL, token string) string {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || token == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/auth/magic-link/verify"
|
|
q := u.Query()
|
|
q.Set("token", token)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// Close releases resources held by the service.
|
|
func (s *Service) Close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if s.store != nil {
|
|
s.store.Close()
|
|
}
|
|
}
|
|
|
|
func loadOrGenerateKey(path string) ([]byte, error) {
|
|
data, err := securityutil.ReadSecureStorageFile(path, maxHMACKeyFileSize)
|
|
if err == nil {
|
|
if len(data) != hmacKeySize {
|
|
return nil, fmt.Errorf("invalid key file %s: expected %d bytes, got %d", path, hmacKeySize, len(data))
|
|
}
|
|
return append([]byte(nil), data...), nil
|
|
}
|
|
if err != nil && !os.IsNotExist(err) {
|
|
if errors.Is(err, securityutil.ErrUnsafeStorageFile) {
|
|
return nil, fmt.Errorf("unsafe key file %s: %w", path, err)
|
|
}
|
|
return nil, fmt.Errorf("read key file %s: %w", path, err)
|
|
}
|
|
|
|
key := make([]byte, hmacKeySize)
|
|
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
|
return nil, fmt.Errorf("generate key: %w", err)
|
|
}
|
|
if err := securityutil.WriteSecureStorageFile(path, key, privateDirPerm, 0o600); err != nil {
|
|
return nil, fmt.Errorf("write key file %s: %w", path, err)
|
|
}
|
|
log.Info().Str("path", path).Msg("Generated new magic link HMAC key")
|
|
return key, nil
|
|
}
|
|
|
|
func randomToken() (string, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
|
|
return "", fmt.Errorf("generate token: %w", err)
|
|
}
|
|
return magicLinkPrefix + base64.RawURLEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
func signHMAC(key []byte, payload string) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte(payload))
|
|
return mac.Sum(nil)
|
|
}
|