feat: sync configurable OTP delivery providers
This commit is contained in:
parent
c18f773701
commit
6af61f26ba
28 changed files with 2100 additions and 118 deletions
201
internal/otpdelivery/webhook/sender.go
Normal file
201
internal/otpdelivery/webhook/sender.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/otpdelivery"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolVersion = "1"
|
||||
maxResponseBytes = 64 << 10
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
Client *http.Client
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
endpoint *url.URL
|
||||
secret []byte
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Sender, error) {
|
||||
endpoint, err := url.Parse(strings.TrimSpace(cfg.URL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse OTP webhook URL: %w", err)
|
||||
}
|
||||
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
|
||||
return nil, fmt.Errorf("OTP webhook URL scheme must be http or https")
|
||||
}
|
||||
if endpoint.Host == "" || endpoint.User != nil {
|
||||
return nil, fmt.Errorf("OTP webhook URL must contain a host and no userinfo")
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
client := cfg.Client
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Sender{endpoint: endpoint, secret: []byte(cfg.Secret), client: client, logger: logger}, nil
|
||||
}
|
||||
|
||||
type requestBody struct {
|
||||
Version string `json:"version"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Purpose otpdelivery.Purpose `json:"purpose"`
|
||||
Channel otpdelivery.Channel `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type responseBody struct {
|
||||
Accepted *bool `json:"accepted"`
|
||||
MessageID string `json:"message_id"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
func (s *Sender) Deliver(ctx context.Context, delivery otpdelivery.Request) (otpdelivery.Result, error) {
|
||||
now := time.Now()
|
||||
if err := delivery.Validate(now); err != nil {
|
||||
return otpdelivery.Result{}, err
|
||||
}
|
||||
expiresIn := int64(delivery.ExpiresAt.Sub(now) / time.Second)
|
||||
if expiresIn < 1 {
|
||||
expiresIn = 1
|
||||
}
|
||||
body, err := json.Marshal(requestBody{
|
||||
Version: protocolVersion,
|
||||
DeliveryID: delivery.DeliveryID,
|
||||
Purpose: delivery.Purpose,
|
||||
Channel: delivery.Channel,
|
||||
Recipient: delivery.Recipient,
|
||||
Code: delivery.Code,
|
||||
ExpiresAt: delivery.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
ExpiresIn: expiresIn,
|
||||
Locale: delivery.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
return otpdelivery.Result{}, fmt.Errorf("encode OTP webhook request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return otpdelivery.Result{}, fmt.Errorf("create OTP webhook request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Idempotency-Key", delivery.DeliveryID)
|
||||
timestamp := fmt.Sprint(now.Unix())
|
||||
req.Header.Set("X-Telesrv-Timestamp", timestamp)
|
||||
if len(s.secret) > 0 {
|
||||
req.Header.Set("X-Telesrv-Signature", signature(s.secret, timestamp, body))
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.Warn("OTP webhook delivery outcome is unknown",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.String("purpose", string(delivery.Purpose)),
|
||||
zap.String("channel", string(delivery.Channel)),
|
||||
zap.Error(err))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
||||
if readErr != nil || len(payload) > maxResponseBytes {
|
||||
cause := readErr
|
||||
if cause == nil {
|
||||
cause = fmt.Errorf("response exceeds %d bytes", maxResponseBytes)
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
s.logger.Warn("OTP webhook acknowledgement is unreadable",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Error(cause))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
|
||||
}
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{StatusCode: resp.StatusCode, Code: "RESPONSE_UNREADABLE", Retryable: resp.StatusCode >= 500}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
provider := decodeResponse(payload)
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: provider.ErrorCode,
|
||||
Retryable: provider.Retryable || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500,
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return otpdelivery.Result{}, nil
|
||||
}
|
||||
provider := decodeResponse(payload)
|
||||
if provider.Accepted == nil {
|
||||
cause := fmt.Errorf("2xx response is missing accepted")
|
||||
s.logger.Warn("OTP webhook acknowledgement is invalid",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Error(cause))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
|
||||
}
|
||||
if !*provider.Accepted {
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: provider.ErrorCode,
|
||||
Retryable: provider.Retryable,
|
||||
}
|
||||
}
|
||||
return otpdelivery.Result{ProviderMessageID: provider.MessageID}, nil
|
||||
}
|
||||
|
||||
func decodeResponse(payload []byte) responseBody {
|
||||
var result responseBody
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
return result
|
||||
}
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return responseBody{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func signature(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("."))
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
165
internal/otpdelivery/webhook/sender_test.go
Normal file
165
internal/otpdelivery/webhook/sender_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/otpdelivery"
|
||||
)
|
||||
|
||||
func TestDeliverSendsVersionedSignedRequest(t *testing.T) {
|
||||
secret := "webhook-secret"
|
||||
var got requestBody
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost || r.Header.Get("Idempotency-Key") != "otp_test_delivery" {
|
||||
t.Errorf("method/idempotency = %s/%q", r.Method, r.Header.Get("Idempotency-Key"))
|
||||
}
|
||||
timestamp := r.Header.Get("X-Telesrv-Timestamp")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
wantSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if r.Header.Get("X-Telesrv-Signature") != wantSignature {
|
||||
t.Errorf("signature = %q, want %q", r.Header.Get("X-Telesrv-Signature"), wantSignature)
|
||||
}
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"accepted":true,"message_id":"provider-42"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender, err := New(Config{URL: server.URL, Secret: secret, Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
expiresAt := time.Now().Add(5 * time.Minute).UTC()
|
||||
result, err := sender.Deliver(context.Background(), otpdelivery.Request{
|
||||
DeliveryID: "otp_test_delivery",
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: "alice@example.test",
|
||||
Code: "482913",
|
||||
ExpiresAt: expiresAt,
|
||||
Locale: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Deliver: %v", err)
|
||||
}
|
||||
if result.ProviderMessageID != "provider-42" {
|
||||
t.Fatalf("message id = %q", result.ProviderMessageID)
|
||||
}
|
||||
if got.Version != protocolVersion || got.DeliveryID != "otp_test_delivery" ||
|
||||
got.Purpose != otpdelivery.PurposeLoginEmail || got.Channel != otpdelivery.ChannelEmail ||
|
||||
got.Recipient != "alice@example.test" || got.Code != "482913" || got.Locale != "zh-CN" || got.ExpiresIn < 298 {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverExplicitRejectionIsDefinite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, `{"accepted":false,"error_code":"RATE_LIMITED","retryable":true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
var rejected *otpdelivery.RejectedError
|
||||
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTooManyRequests || rejected.Code != "RATE_LIMITED" || !rejected.Retryable {
|
||||
t.Fatalf("rejection = %#v err=%v", rejected, err)
|
||||
}
|
||||
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
t.Fatalf("explicit rejection marked unknown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverMalformedSuccessIsOutcomeUnknown(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
t.Fatalf("err = %v, want unknown outcome", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverTransportFailureIsOutcomeUnknown(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection reset after write")
|
||||
})}
|
||||
sender, err := New(Config{URL: "https://otp.example.test/send", Client: client})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) || !strings.Contains(err.Error(), "connection reset") {
|
||||
t.Fatalf("err = %v, want unknown transport outcome", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverDoesNotFollowRedirect(t *testing.T) {
|
||||
targetCalled := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
targetCalled = true
|
||||
}))
|
||||
defer target.Close()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target.URL)
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
var rejected *otpdelivery.RejectedError
|
||||
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("err = %v, want redirect rejection", err)
|
||||
}
|
||||
if targetCalled {
|
||||
t.Fatal("redirect target received OTP")
|
||||
}
|
||||
}
|
||||
|
||||
func validRequest() otpdelivery.Request {
|
||||
return otpdelivery.Request{
|
||||
DeliveryID: "otp_valid",
|
||||
Purpose: otpdelivery.PurposeLoginSMS,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: "15550001001",
|
||||
Code: "12345",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue