feat: sync configurable OTP delivery providers

This commit is contained in:
A 2026-07-17 17:04:39 +08:00
parent c18f773701
commit 6af61f26ba
28 changed files with 2100 additions and 118 deletions

View file

@ -0,0 +1,49 @@
# OTP Webhook example
This command implements the `telesrv` OTP Webhook v1 receiving side with only
the Go standard library. It validates the signed request, rejects expired or
invalid payloads, and deduplicates concurrent or repeated delivery IDs.
The default `exampleDelivery` function deliberately does **not** send a real
email/SMS and does not print the code or recipient. For local debugging only,
set `TELESRV_OTP_EXAMPLE_LOG_CODE=true` to print the received code while still
redacting the recipient. Replace that one function with the API call for your
email or SMS provider before real use.
## Run
```powershell
$env:TELESRV_OTP_EXAMPLE_SECRET = 'replace-with-a-random-secret'
$env:TELESRV_OTP_EXAMPLE_LOG_CODE = 'true' # local testing only
go run ./cmd/otpwebhook-example
```
The default endpoints are:
- `POST http://127.0.0.1:2800/v1/otp/deliveries`
- `GET http://127.0.0.1:2800/healthz`
Then configure `telesrv` with the same secret:
```dotenv
TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=webhook
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=webhook
TELESRV_OTP_WEBHOOK_URL=http://127.0.0.1:2800/v1/otp/deliveries
TELESRV_OTP_WEBHOOK_SECRET=replace-with-a-random-secret
```
The example accepts these optional settings:
```dotenv
TELESRV_OTP_EXAMPLE_ADDR=127.0.0.1:2800
TELESRV_OTP_EXAMPLE_MAX_SKEW=5m
TELESRV_OTP_EXAMPLE_LOG_CODE=false
```
The idempotency registry is intentionally in memory. A production receiver
must put delivery IDs and the downstream provider message ID in durable shared
storage before running more than one instance or surviving restarts. Pass the
same delivery ID to a downstream provider when it supports idempotency. The
example remembers both successful and failed outcomes until the code expires,
because an apparent downstream failure may have happened after it sent the
message.

View file

@ -0,0 +1,397 @@
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)
}

View file

@ -0,0 +1,164 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
)
func TestDeliveryAcceptsSignedRequestAndDeduplicatesReplay(t *testing.T) {
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
var calls atomic.Int32
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
calls.Add(1)
return "provider-message-1", nil
})
body := marshalRequest(t, validRequest(now))
for range 2 {
response := performDelivery(t, app.routes(), body, "otp_test_1", "test-secret", now)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var result deliveryResponse
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.Accepted || result.MessageID != "provider-message-1" {
t.Fatalf("unexpected response: %+v", result)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("deliver calls = %d, want 1", got)
}
}
func TestDeliveryRejectsIdempotencyConflict(t *testing.T) {
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
return "provider-message-1", nil
})
first := validRequest(now)
response := performDelivery(t, app.routes(), marshalRequest(t, first), first.DeliveryID, "test-secret", now)
if response.Code != http.StatusOK {
t.Fatalf("first status = %d", response.Code)
}
second := first
second.Code = "654321"
response = performDelivery(t, app.routes(), marshalRequest(t, second), second.DeliveryID, "test-secret", now)
if response.Code != http.StatusConflict {
t.Fatalf("conflict status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDeliveryRejectsInvalidSignature(t *testing.T) {
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
t.Fatal("deliver must not be called")
return "", nil
})
request := validRequest(now)
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "wrong-secret", now)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDeliveryRejectsExpiredCode(t *testing.T) {
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
t.Fatal("deliver must not be called")
return "", nil
})
request := validRequest(now)
request.ExpiresAt = now.Add(-time.Second)
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "test-secret", now)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDeliveryFailureIsAlsoDeduplicated(t *testing.T) {
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
var calls atomic.Int32
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
calls.Add(1)
return "", errors.New("downstream outcome unknown")
})
request := validRequest(now)
body := marshalRequest(t, request)
for range 2 {
response := performDelivery(t, app.routes(), body, request.DeliveryID, "test-secret", now)
if response.Code != http.StatusBadGateway {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("deliver calls = %d, want 1", got)
}
}
func testApplication(now time.Time, deliver deliveryFunc) *application {
return newApplication(
"test-secret",
5*time.Minute,
func() time.Time { return now },
deliver,
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
}
func validRequest(now time.Time) deliveryRequest {
return deliveryRequest{
Version: "1",
DeliveryID: "otp_test_1",
Purpose: "login_email",
Channel: "email",
Recipient: "alice@example.test",
Code: "123456",
ExpiresAt: now.Add(5 * time.Minute),
ExpiresIn: 300,
Locale: "zh-CN",
}
}
func marshalRequest(t *testing.T, request deliveryRequest) []byte {
t.Helper()
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
return body
}
func performDelivery(
t *testing.T,
handler http.Handler,
body []byte,
deliveryID string,
secret string,
now time.Time,
) *httptest.ResponseRecorder {
t.Helper()
timestampText := strconv.FormatInt(now.Unix(), 10)
request := httptest.NewRequest(http.MethodPost, "/v1/otp/deliveries", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", deliveryID)
request.Header.Set("X-Telesrv-Timestamp", timestampText)
request.Header.Set("X-Telesrv-Signature", signatureFor([]byte(secret), timestampText, body))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}