Pulse/internal/api/magic_link.go
2026-03-18 16:06:30 +00:00

354 lines
8.7 KiB
Go

package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
"github.com/rs/zerolog/log"
)
var (
ErrMagicLinkInvalidToken = errors.New("magic link token is invalid")
ErrMagicLinkExpired = errors.New("magic link token is expired")
ErrMagicLinkUsed = errors.New("magic link token already used")
)
const (
magicLinkDefaultTTL = 15 * time.Minute
magicLinkStoreCleanupInterval = 5 * time.Minute
magicLinkRateLimitCount = 3
magicLinkRateLimitWindow = 1 * time.Hour
)
type MagicLinkEmailer interface {
SendMagicLink(to, magicLinkURL string) error
}
// LogEmailer logs the magic link URL instead of sending email.
// This is intentionally used for dev/staging until a real email subsystem lands.
type LogEmailer struct{}
func (e LogEmailer) SendMagicLink(to, magicLinkURL string) error {
// SECURITY: Do not log full magic link URLs (token in query string). Even opaque tokens are auth
// credentials and can be replayed if leaked via logs.
log.Info().
Str("to", to).
Str("magic_link_url_redacted", redactMagicLinkURL(magicLinkURL)).
Msg("Magic link email (log-only)")
return nil
}
type MagicLinkToken struct {
Email string
OrgID string
ExpiresAt time.Time
Used bool
Token string // Opaque random token ID (not self-describing)
}
type MagicLinkStore interface {
Put(tokenHash []byte, token *MagicLinkToken) error
Consume(tokenHash []byte, now time.Time) (*MagicLinkToken, error)
DeleteExpired(now time.Time)
Stop()
}
type InMemoryMagicLinkStore struct {
mu sync.RWMutex
tokens map[string]*MagicLinkToken // keyed by hex(tokenHash)
stopCleanup chan struct{}
}
func NewInMemoryMagicLinkStore() *InMemoryMagicLinkStore {
s := &InMemoryMagicLinkStore{
tokens: make(map[string]*MagicLinkToken),
stopCleanup: make(chan struct{}),
}
// Tokens are short-lived; periodic cleanup keeps memory bounded.
go func() {
ticker := time.NewTicker(magicLinkStoreCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.DeleteExpired(time.Now().UTC())
case <-s.stopCleanup:
return
}
}
}()
return s
}
func (s *InMemoryMagicLinkStore) Stop() {
select {
case <-s.stopCleanup:
// already stopped
default:
close(s.stopCleanup)
}
}
func (s *InMemoryMagicLinkStore) Put(tokenHash []byte, token *MagicLinkToken) error {
if len(tokenHash) == 0 {
return fmt.Errorf("tokenHash is required")
}
if token == nil || token.Email == "" || token.OrgID == "" || token.ExpiresAt.IsZero() {
return fmt.Errorf("token record is required")
}
s.mu.Lock()
defer s.mu.Unlock()
key := hex.EncodeToString(tokenHash)
clone := *token
clone.Used = false
clone.Token = ""
s.tokens[key] = &clone
return nil
}
func (s *InMemoryMagicLinkStore) Consume(tokenHash []byte, now time.Time) (*MagicLinkToken, error) {
if len(tokenHash) == 0 {
return nil, ErrMagicLinkInvalidToken
}
key := hex.EncodeToString(tokenHash)
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tokens[key]
if !ok || t == nil {
return nil, ErrMagicLinkInvalidToken
}
if now.After(t.ExpiresAt) {
return nil, ErrMagicLinkExpired
}
if t.Used {
return nil, ErrMagicLinkUsed
}
t.Used = true
clone := *t
return &clone, nil
}
func (s *InMemoryMagicLinkStore) DeleteExpired(now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
for k, tok := range s.tokens {
if tok == nil {
delete(s.tokens, k)
continue
}
if now.After(tok.ExpiresAt) {
delete(s.tokens, k)
}
}
}
type MagicLinkService struct {
hmacKey []byte // used to derive a stable lookup key for opaque tokens (HMAC-SHA256(token))
store MagicLinkStore
emailer MagicLinkEmailer
ttl time.Duration
now func() time.Time
// Per-email request limiting: max 3 magic link sends per email per hour.
requestLimiter *RateLimiter
}
func NewMagicLinkServiceForDataPath(dataPath string, emailer MagicLinkEmailer) (*MagicLinkService, error) {
cm, err := crypto.NewCryptoManagerAt(dataPath)
if err != nil {
return nil, fmt.Errorf("init crypto manager: %w", err)
}
key, err := cm.DeriveKey("magic-link-hmac-v1", 32)
if err != nil {
return nil, fmt.Errorf("derive hmac key: %w", err)
}
store, err := NewSQLiteMagicLinkStore(dataPath)
if err != nil {
return nil, fmt.Errorf("init magic link store: %w", err)
}
return NewMagicLinkServiceWithKey(key, store, emailer, nil), nil
}
func NewMagicLinkServiceWithKey(hmacKey []byte, store MagicLinkStore, emailer MagicLinkEmailer, limiter *RateLimiter) *MagicLinkService {
if store == nil {
store = NewInMemoryMagicLinkStore()
}
if emailer == nil {
emailer = LogEmailer{}
}
if limiter == nil {
limiter = NewRateLimiter(magicLinkRateLimitCount, magicLinkRateLimitWindow)
}
return &MagicLinkService{
hmacKey: append([]byte(nil), hmacKey...),
store: store,
emailer: emailer,
ttl: magicLinkDefaultTTL,
now: time.Now,
requestLimiter: limiter,
}
}
func (s *MagicLinkService) Stop() {
if s == nil {
return
}
if s.store != nil {
s.store.Stop()
}
if s.requestLimiter != nil {
s.requestLimiter.Stop()
}
}
func (s *MagicLinkService) AllowRequest(email string) bool {
if s == nil || s.requestLimiter == nil {
return true
}
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return false
}
return s.requestLimiter.Allow(email)
}
func (s *MagicLinkService) GenerateToken(email, orgID string) (string, error) {
if s == nil {
return "", fmt.Errorf("magic link service not configured")
}
if len(s.hmacKey) == 0 {
return "", fmt.Errorf("magic link hmac key not configured")
}
email = strings.ToLower(strings.TrimSpace(email))
orgID = strings.TrimSpace(orgID)
if email == "" || orgID == "" {
return "", fmt.Errorf("email and orgID are required")
}
if !isValidOrganizationID(orgID) {
return "", fmt.Errorf("orgID is invalid")
}
expiresAt := s.now().UTC().Add(s.ttl)
// Store expiry at second precision for stable comparisons across stores.
expiresAt = time.Unix(expiresAt.Unix(), 0).UTC()
token, err := randomOpaqueTokenID()
if err != nil {
return "", err
}
tokenHash := signHMACSHA256(s.hmacKey, token)
if err := s.store.Put(tokenHash, &MagicLinkToken{
Email: email,
OrgID: orgID,
ExpiresAt: expiresAt,
Used: false,
Token: "",
}); err != nil {
return "", err
}
return token, nil
}
func (s *MagicLinkService) ValidateToken(token string) (*MagicLinkToken, error) {
if s == nil {
return nil, ErrMagicLinkInvalidToken
}
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrMagicLinkInvalidToken
}
now := s.now().UTC()
tokenHash := signHMACSHA256(s.hmacKey, token)
used, err := s.store.Consume(tokenHash, now)
if err != nil {
return nil, err
}
used.Token = token
return used, nil
}
func (s *MagicLinkService) BuildMagicLinkURL(baseURL, token string) (string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return "", fmt.Errorf("baseURL is required")
}
if token == "" {
return "", fmt.Errorf("token is required")
}
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("baseURL must include scheme and host")
}
u.Path = strings.TrimRight(u.Path, "/") + "/api/public/magic-link/verify"
q := u.Query()
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String(), nil
}
// SendMagicLink is a convenience wrapper when a full base URL is available.
func (s *MagicLinkService) SendMagicLink(email, orgID, token, baseURL string) error {
if s == nil || s.emailer == nil {
return fmt.Errorf("magic link emailer not configured")
}
magicURL, err := s.BuildMagicLinkURL(baseURL, token)
if err != nil {
return fmt.Errorf("build magic link URL: %w", err)
}
if err := s.emailer.SendMagicLink(email, magicURL); err != nil {
return fmt.Errorf("send magic link email: %w", err)
}
return nil
}
func signHMACSHA256(key []byte, payload string) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(payload))
return mac.Sum(nil)
}
func randomOpaqueTokenID() (string, error) {
// 32 bytes => 43 chars base64url (no padding). Prefix enables future migrations/format checks.
raw := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
return "", fmt.Errorf("token id: %w", err)
}
return "ml1_" + base64.RawURLEncoding.EncodeToString(raw), nil
}
func redactMagicLinkURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return ""
}
// Keep scheme/host/path. Drop query/fragment entirely to avoid logging credentials.
u.RawQuery = ""
u.Fragment = ""
return u.String()
}