mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
212 lines
5.4 KiB
Go
212 lines
5.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
magicLinkTTL = 15 * time.Minute
|
|
magicLinkPrefix = "ml1_"
|
|
hmacKeyFile = ".cp_magic_link_key"
|
|
hmacKeySize = 32
|
|
)
|
|
|
|
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
|
|
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) {
|
|
if err := ensureOwnerOnlyDir(cpDataDir); err != nil {
|
|
return nil, fmt.Errorf("ensure cp data dir: %w", err)
|
|
}
|
|
|
|
key, err := loadOrGenerateKey(filepath.Join(cpDataDir, hmacKeyFile))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("magic link hmac key: %w", err)
|
|
}
|
|
|
|
store, err := NewStore(cpDataDir)
|
|
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 := os.ReadFile(path)
|
|
if err == nil && len(data) >= hmacKeySize {
|
|
return data[:hmacKeySize], nil
|
|
}
|
|
if err != nil && !os.IsNotExist(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 := os.WriteFile(path, key, 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)
|
|
}
|