Ctrl K
ComunidadGuías…

cmd/otpwebhook-example/main.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"mime"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"
)

const maxRequestBody = 64 << 10

var errIdempotencyConflict = errors.New("idempotency key was already used with a different payload")

type config struct {
	address string
	secret  string
	maxSkew time.Duration
	logCode bool
}

type deliveryRequest struct {
	Version    string    `json:"version"`
	DeliveryID string    `json:"delivery_id"`
	Purpose    string    `json:"purpose"`
	Channel    string    `json:"channel"`
	Recipient  string    `json:"recipient"`
	Code       string    `json:"code"`
	ExpiresAt  time.Time `json:"expires_at"`
	ExpiresIn  int64     `json:"expires_in"`
	Locale     string    `json:"locale,omitempty"`
}

type deliveryResponse struct {
	Accepted  bool   `json:"accepted"`
	MessageID string `json:"message_id,omitempty"`
	ErrorCode string `json:"error_code,omitempty"`
	Retryable *bool  `json:"retryable,omitempty"`
}

type deliveryFunc func(context.Context, deliveryRequest) (string, error)

type receipt struct {
	fingerprint [sha256.Size]byte
	expiresAt   time.Time
	done        chan struct{}
	messageID   string
	err         error
	completed   bool
}

type application struct {
	secret  []byte
	maxSkew time.Duration
	now     func() time.Time
	deliver deliveryFunc
	logger  *slog.Logger

	mu       sync.Mutex
	receipts map[string]*receipt
}

func main() {
	if err := run(); err != nil {
		slog.Error("OTP webhook example stopped", "error", err)
		os.Exit(1)
	}
}

func run() error {
	cfg, err := loadConfig()
	if err != nil {
		return err
	}

	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	app := newApplication(cfg.secret, cfg.maxSkew, time.Now, exampleDelivery(logger, cfg.logCode), logger)
	server := &http.Server{
		Addr:              cfg.address,
		Handler:           app.routes(),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       30 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if cfg.secret == "" {
		logger.Warn("signature verification is disabled; set TELESRV_OTP_EXAMPLE_SECRET outside local development")
	}
	if cfg.logCode {
		logger.Warn("OTP code logging is enabled for local testing")
	}
	logger.Info("OTP webhook example listening", "address", cfg.address)

	serverErr := make(chan error, 1)
	go func() {
		serverErr <- server.ListenAndServe()
	}()

	select {
	case err := <-serverErr:
		if errors.Is(err, http.ErrServerClosed) {
			return nil
		}
		return err
	case <-ctx.Done():
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := server.Shutdown(shutdownCtx); err != nil {
			return fmt.Errorf("shutdown HTTP server: %w", err)
		}
		err := <-serverErr
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			return err
		}
		return nil
	}
}

func loadConfig() (config, error) {
	cfg := config{
		address: envOrDefault("TELESRV_OTP_EXAMPLE_ADDR", "127.0.0.1:2800"),
		secret:  os.Getenv("TELESRV_OTP_EXAMPLE_SECRET"),
		maxSkew: 5 * time.Minute,
	}
	if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_MAX_SKEW")); raw != "" {
		parsed, err := time.ParseDuration(raw)
		if err != nil || parsed <= 0 {
			return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_MAX_SKEW must be a positive duration")
		}
		cfg.maxSkew = parsed
	}
	if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_LOG_CODE")); raw != "" {
		parsed, err := strconv.ParseBool(raw)
		if err != nil {
			return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_LOG_CODE must be a boolean")
		}
		cfg.logCode = parsed
	}
	return cfg, nil
}

func envOrDefault(name, fallback string) string {
	if value := strings.TrimSpace(os.Getenv(name)); value != "" {
		return value
	}
	return fallback
}

func newApplication(
	secret string,
	maxSkew time.Duration,
	now func() time.Time,
	deliver deliveryFunc,
	logger *slog.Logger,
) *application {
	return &application{
		secret:   []byte(secret),
		maxSkew:  maxSkew,
		now:      now,
		deliver:  deliver,
		logger:   logger,
		receipts: make(map[string]*receipt),
	}
}

func (a *application) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.WriteHeader(http.StatusOK)
		_, _ = io.WriteString(w, "ok\n")
	})
	mux.HandleFunc("POST /v1/otp/deliveries", a.handleDelivery)
	return mux
}

func (a *application) handleDelivery(w http.ResponseWriter, r *http.Request) {
	mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || mediaType != "application/json" {
		writeError(w, http.StatusUnsupportedMediaType, "CONTENT_TYPE_INVALID", false)
		return
	}

	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBody))
	if err != nil {
		writeError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", false)
		return
	}
	if err := a.verifySignature(r.Header, body); err != nil {
		writeError(w, http.StatusUnauthorized, "SIGNATURE_INVALID", false)
		return
	}

	var request deliveryRequest
	decoder := json.NewDecoder(bytes.NewReader(body))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&request); err != nil {
		writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
		return
	}
	if err := ensureJSONEOF(decoder); err != nil {
		writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
		return
	}
	if err := validateRequest(request, r.Header.Get("Idempotency-Key"), a.now()); err != nil {
		writeError(w, http.StatusBadRequest, "REQUEST_INVALID", false)
		return
	}

	messageID, err := a.deliverOnce(r.Context(), request, body)
	if errors.Is(err, errIdempotencyConflict) {
		writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", false)
		return
	}
	if err != nil {
		a.logger.Warn("OTP delivery failed", "delivery_id", request.DeliveryID)
		writeError(w, http.StatusBadGateway, "DELIVERY_FAILED", true)
		return
	}
	writeJSON(w, http.StatusOK, deliveryResponse{Accepted: true, MessageID: messageID})
}

func (a *application) verifySignature(header http.Header, body []byte) error {
	if len(a.secret) == 0 {
		return nil
	}

	timestamp := header.Get("X-Telesrv-Timestamp")
	unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return errors.New("invalid timestamp")
	}
	delta := a.now().Sub(time.Unix(unixSeconds, 0))
	if delta < 0 {
		delta = -delta
	}
	if delta > a.maxSkew {
		return errors.New("timestamp outside allowed skew")
	}

	provided := header.Get("X-Telesrv-Signature")
	expected := signatureFor(a.secret, timestamp, body)
	if !hmac.Equal([]byte(provided), []byte(expected)) {
		return errors.New("signature mismatch")
	}
	return nil
}

func signatureFor(secret []byte, timestamp string, body []byte) string {
	mac := hmac.New(sha256.New, secret)
	_, _ = io.WriteString(mac, timestamp)
	_, _ = mac.Write([]byte{'.'})
	_, _ = mac.Write(body)
	return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

func validateRequest(request deliveryRequest, idempotencyKey string, now time.Time) error {
	if request.Version != "1" {
		return errors.New("unsupported version")
	}
	if request.DeliveryID == "" || len(request.DeliveryID) > 128 || request.DeliveryID != idempotencyKey {
		return errors.New("invalid delivery ID")
	}
	if len(request.Recipient) == 0 || len(request.Recipient) > 512 {
		return errors.New("invalid recipient")
	}
	if len(request.Code) == 0 || len(request.Code) > 32 {
		return errors.New("invalid code")
	}
	if len(request.Locale) > 64 || request.ExpiresIn < 0 || request.ExpiresAt.IsZero() || !request.ExpiresAt.After(now) {
		return errors.New("invalid expiry or locale")
	}

	expectedChannel, ok := map[string]string{
		"login_email":        "email",
		"login_email_setup":  "email",
		"login_email_change": "email",
		"login_sms":          "sms",
		"change_phone":       "sms",
	}[request.Purpose]
	if !ok || request.Channel != expectedChannel {
		return errors.New("invalid purpose or channel")
	}
	return nil
}

func ensureJSONEOF(decoder *json.Decoder) error {
	var extra any
	if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
		if err == nil {
			return errors.New("multiple JSON values")
		}
		return err
	}
	return nil
}

func (a *application) deliverOnce(
	ctx context.Context,
	request deliveryRequest,
	body []byte,
) (string, error) {
	fingerprint := sha256.Sum256(body)
	now := a.now()

	a.mu.Lock()
	for id, existing := range a.receipts {
		if existing.completed && !existing.expiresAt.After(now) {
			delete(a.receipts, id)
		}
	}
	if existing, ok := a.receipts[request.DeliveryID]; ok {
		if existing.fingerprint != fingerprint {
			a.mu.Unlock()
			return "", errIdempotencyConflict
		}
		done := existing.done
		a.mu.Unlock()

		select {
		case <-done:
			a.mu.Lock()
			messageID, err := existing.messageID, existing.err
			a.mu.Unlock()
			return messageID, err
		case <-ctx.Done():
			return "", ctx.Err()
		}
	}

	current := &receipt{
		fingerprint: fingerprint,
		expiresAt:   request.ExpiresAt,
		done:        make(chan struct{}),
	}
	a.receipts[request.DeliveryID] = current
	a.mu.Unlock()

	messageID, err := a.deliver(ctx, request)

	a.mu.Lock()
	current.messageID = messageID
	current.err = err
	current.completed = true
	close(current.done)
	a.mu.Unlock()
	return messageID, err
}

// exampleDelivery is the extension point for an email/SMS provider. It does
// not send a real message. Replace this function with a provider call before
// real use. Code logging is an explicit local-debug option.
func exampleDelivery(logger *slog.Logger, logCode bool) deliveryFunc {
	return func(_ context.Context, request deliveryRequest) (string, error) {
		recipientHash := sha256.Sum256([]byte(request.Recipient))
		messageHash := sha256.Sum256([]byte(request.DeliveryID))
		attributes := []any{
			"delivery_id", request.DeliveryID,
			"purpose", request.Purpose,
			"channel", request.Channel,
			"recipient_sha256", hex.EncodeToString(recipientHash[:6]),
		}
		if logCode {
			attributes = append(attributes, "code", request.Code)
		}
		logger.Info("OTP delivery accepted by example adapter", attributes...)
		return "example_" + hex.EncodeToString(messageHash[:8]), nil
	}
}

func writeError(w http.ResponseWriter, status int, code string, retryable bool) {
	writeJSON(w, status, deliveryResponse{Accepted: false, ErrorCode: code, Retryable: &retryable})
}

func writeJSON(w http.ResponseWriter, status int, response deliveryResponse) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(response)
}