mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
354 lines
8.7 KiB
Go
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()
|
|
}
|