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,129 @@
// Package otpdelivery defines the outbound boundary for one-time-code
// delivery. Code generation, persistence and verification stay in the app
// services; implementations in this package only deliver an already-issued
// code through a concrete channel.
package otpdelivery
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
type Channel string
const (
ChannelEmail Channel = "email"
ChannelSMS Channel = "sms"
)
type Purpose string
const (
PurposeLoginEmail Purpose = "login_email"
PurposeLoginSMS Purpose = "login_sms"
PurposeLoginEmailSetup Purpose = "login_email_setup"
PurposeLoginEmailChange Purpose = "login_email_change"
PurposeChangePhone Purpose = "change_phone"
)
type Request struct {
DeliveryID string
Purpose Purpose
Channel Channel
Recipient string
Code string
ExpiresAt time.Time
Locale string
}
func (r Request) Validate(now time.Time) error {
if strings.TrimSpace(r.DeliveryID) == "" || len(r.DeliveryID) > 128 {
return fmt.Errorf("delivery id is empty or too long")
}
switch r.Purpose {
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone:
default:
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
}
switch r.Channel {
case ChannelEmail, ChannelSMS:
default:
return fmt.Errorf("unsupported delivery channel %q", r.Channel)
}
if strings.TrimSpace(r.Recipient) == "" || len(r.Recipient) > 512 {
return fmt.Errorf("delivery recipient is empty or too long")
}
if strings.TrimSpace(r.Code) == "" || len(r.Code) > 64 {
return fmt.Errorf("delivery code is empty or too long")
}
if len(r.Locale) > 32 {
return fmt.Errorf("delivery locale is too long")
}
if !r.ExpiresAt.After(now) {
return fmt.Errorf("delivery expiry is not in the future")
}
return nil
}
type Result struct {
ProviderMessageID string
}
type Sender interface {
Deliver(ctx context.Context, req Request) (Result, error)
}
// ErrOutcomeUnknown marks a transport result for which the provider may have
// accepted the request, but telesrv did not receive a valid acknowledgement.
// Callers must keep the issued code usable; deleting it could invalidate a code
// which has already reached the recipient.
var ErrOutcomeUnknown = errors.New("otp delivery outcome unknown")
type OutcomeUnknownError struct {
Cause error
}
func (e *OutcomeUnknownError) Error() string {
if e == nil || e.Cause == nil {
return ErrOutcomeUnknown.Error()
}
return fmt.Sprintf("%s: %v", ErrOutcomeUnknown, e.Cause)
}
func (e *OutcomeUnknownError) Unwrap() error {
if e == nil || e.Cause == nil {
return ErrOutcomeUnknown
}
return errors.Join(ErrOutcomeUnknown, e.Cause)
}
// RejectedError is a provider acknowledgement that the request was not
// accepted. It is safe for the caller to invalidate the corresponding code.
type RejectedError struct {
StatusCode int
Code string
Retryable bool
}
func (e *RejectedError) Error() string {
if e == nil {
return "otp delivery rejected"
}
if e.Code != "" {
return fmt.Sprintf("otp delivery rejected: status=%d code=%s retryable=%t", e.StatusCode, e.Code, e.Retryable)
}
return fmt.Sprintf("otp delivery rejected: status=%d retryable=%t", e.StatusCode, e.Retryable)
}
func NewDeliveryID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate otp delivery id: %w", err)
}
return "otp_" + hex.EncodeToString(raw[:]), nil
}

View file

@ -0,0 +1,36 @@
package otpdelivery
import (
"strings"
"testing"
"time"
)
func TestNewDeliveryIDIsOpaqueAndUnique(t *testing.T) {
first, err := NewDeliveryID()
if err != nil {
t.Fatalf("first id: %v", err)
}
second, err := NewDeliveryID()
if err != nil {
t.Fatalf("second id: %v", err)
}
if first == second || !strings.HasPrefix(first, "otp_") || len(first) != len("otp_")+32 {
t.Fatalf("ids = %q / %q", first, second)
}
}
func TestRequestRejectsExpiredCode(t *testing.T) {
now := time.Now()
err := (Request{
DeliveryID: "otp_expired",
Purpose: PurposeLoginEmail,
Channel: ChannelEmail,
Recipient: "a@example.test",
Code: "123456",
ExpiresAt: now,
}).Validate(now)
if err == nil {
t.Fatal("expired request accepted")
}
}

View file

@ -0,0 +1,165 @@
package smtp
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"mime"
"net"
stdmail "net/mail"
stdsmtp "net/smtp"
"strings"
"time"
"telesrv/internal/otpdelivery"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
FromName string
TLSMode string
Timeout time.Duration
}
type Sender struct {
cfg Config
}
func New(cfg Config) *Sender {
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
cfg.TLSMode = strings.ToLower(strings.TrimSpace(cfg.TLSMode))
if cfg.TLSMode == "" {
cfg.TLSMode = "starttls"
}
if strings.TrimSpace(cfg.From) == "" {
cfg.From = cfg.Username
}
return &Sender{cfg: cfg}
}
func (s *Sender) Deliver(ctx context.Context, req otpdelivery.Request) (otpdelivery.Result, error) {
if err := req.Validate(time.Now()); err != nil {
return otpdelivery.Result{}, err
}
if req.Channel != otpdelivery.ChannelEmail {
return otpdelivery.Result{}, fmt.Errorf("smtp cannot deliver channel %q", req.Channel)
}
ttl := time.Until(req.ExpiresAt)
subject := "Your telesrv login code"
body := fmt.Sprintf("Your telesrv login code is %s.\n\nThis code expires in %s. If you did not request it, ignore this email.\n", req.Code, humanTTL(ttl))
if err := s.send(ctx, req.Recipient, subject, body); err != nil {
return otpdelivery.Result{}, err
}
return otpdelivery.Result{}, nil
}
func (s *Sender) send(ctx context.Context, to, subject, body string) error {
if strings.TrimSpace(s.cfg.Host) == "" {
return fmt.Errorf("smtp host is empty")
}
from := strings.TrimSpace(s.cfg.From)
if from == "" {
return fmt.Errorf("smtp from is empty")
}
if _, err := stdmail.ParseAddress(to); err != nil {
return fmt.Errorf("parse recipient: %w", err)
}
fromAddr := from
if s.cfg.FromName != "" {
fromAddr = (&stdmail.Address{Name: s.cfg.FromName, Address: from}).String()
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
var d net.Dialer
d.Timeout = s.cfg.Timeout
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer conn.Close()
mode := strings.ToLower(strings.TrimSpace(s.cfg.TLSMode))
var c *stdsmtp.Client
if mode == "tls" {
tlsConn := tls.Client(conn, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return fmt.Errorf("smtp tls handshake: %w", err)
}
c, err = stdsmtp.NewClient(tlsConn, s.cfg.Host)
} else {
c, err = stdsmtp.NewClient(conn, s.cfg.Host)
}
if err != nil {
return fmt.Errorf("new smtp client: %w", err)
}
defer c.Close()
if mode == "starttls" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
} else {
return fmt.Errorf("smtp server does not support STARTTLS")
}
}
if s.cfg.Username != "" {
if err := c.Auth(stdsmtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
msg := buildMessage(fromAddr, to, subject, body)
if _, err := w.Write(msg); err != nil {
_ = w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return c.Quit()
}
func buildMessage(from, to, subject, body string) []byte {
var b bytes.Buffer
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return b.Bytes()
}
func humanTTL(ttl time.Duration) string {
if ttl <= 0 {
return "a short time"
}
// Network and processing time can shave sub-second precision off an exact
// configured TTL. Round up so a five-minute code is not rendered as 4m59s.
ttl = ttl.Round(time.Second)
if ttl%time.Minute == 0 {
minutes := int(ttl / time.Minute)
if minutes == 1 {
return "1 minute"
}
return fmt.Sprintf("%d minutes", minutes)
}
return ttl.String()
}

View file

@ -0,0 +1,31 @@
package smtp
import (
"context"
"strings"
"testing"
"time"
"telesrv/internal/otpdelivery"
)
func TestSenderRejectsNonEmailChannelBeforeDial(t *testing.T) {
sender := New(Config{Host: "smtp.example.test", Port: 25, From: "noreply@example.test"})
_, err := sender.Deliver(context.Background(), otpdelivery.Request{
DeliveryID: "otp_sms",
Purpose: otpdelivery.PurposeLoginSMS,
Channel: otpdelivery.ChannelSMS,
Recipient: "15550001001",
Code: "12345",
ExpiresAt: time.Now().Add(time.Minute),
})
if err == nil || !strings.Contains(err.Error(), "cannot deliver") {
t.Fatalf("err = %v", err)
}
}
func TestHumanTTLRoundsNetworkSkew(t *testing.T) {
if got := humanTTL(5*time.Minute - 200*time.Millisecond); got != "5 minutes" {
t.Fatalf("humanTTL = %q", got)
}
}

View 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))
}

View 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)
}