feat: sync configurable OTP delivery providers
This commit is contained in:
parent
c18f773701
commit
6af61f26ba
28 changed files with 2100 additions and 118 deletions
49
cmd/otpwebhook-example/README.md
Normal file
49
cmd/otpwebhook-example/README.md
Normal 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.
|
||||
397
cmd/otpwebhook-example/main.go
Normal file
397
cmd/otpwebhook-example/main.go
Normal 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)
|
||||
}
|
||||
164
cmd/otpwebhook-example/main_test.go
Normal file
164
cmd/otpwebhook-example/main_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// Command telesrv 是基于 iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -55,8 +55,10 @@ import (
|
|||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
mailpkg "telesrv/internal/mail"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/sfu"
|
||||
|
|
@ -524,18 +526,45 @@ func run(logger *zap.Logger) error {
|
|||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
}
|
||||
var loginEmailSender mailpkg.Sender
|
||||
if cfg.LoginEmailEnable {
|
||||
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
var webhookSender otpdelivery.Sender
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" ||
|
||||
(cfg.LoginEmailEnable && cfg.EmailCodeDeliveryProvider == "webhook") {
|
||||
configured, err := otpwebhook.New(otpwebhook.Config{
|
||||
URL: cfg.OTPWebhookURL,
|
||||
Secret: cfg.OTPWebhookSecret,
|
||||
Timeout: cfg.OTPWebhookTimeout,
|
||||
Logger: logger.Named("otp").Named("webhook"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure OTP webhook: %w", err)
|
||||
}
|
||||
webhookSender = configured
|
||||
logger.Info("OTP Webhook 投递已启用",
|
||||
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
|
||||
zap.Bool("email", cfg.LoginEmailEnable && cfg.EmailCodeDeliveryProvider == "webhook"))
|
||||
}
|
||||
var phoneCodeSender otpdelivery.Sender
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" {
|
||||
phoneCodeSender = webhookSender
|
||||
accountOptions = append(accountOptions, account.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength))
|
||||
}
|
||||
var loginEmailSender otpdelivery.Sender
|
||||
if cfg.LoginEmailEnable {
|
||||
switch cfg.EmailCodeDeliveryProvider {
|
||||
case "webhook":
|
||||
loginEmailSender = webhookSender
|
||||
default:
|
||||
loginEmailSender = otpsmtp.New(otpsmtp.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
})
|
||||
}
|
||||
accountOptions = append(accountOptions,
|
||||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
|
|
@ -693,6 +722,14 @@ func run(logger *zap.Logger) error {
|
|||
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
|
||||
auth.WithCodeTTL(cfg.AuthCodeTTL),
|
||||
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts),
|
||||
auth.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength),
|
||||
auth.WithOTPDeliveryFailureObserver(func(_ context.Context, request otpdelivery.Request, err error) {
|
||||
logger.Named("otp").Warn("附加 OTP provider 投递失败,777000 App-code 保持有效",
|
||||
zap.String("delivery_id", request.DeliveryID),
|
||||
zap.String("purpose", string(request.Purpose)),
|
||||
zap.String("channel", string(request.Channel)),
|
||||
zap.Error(err))
|
||||
}),
|
||||
auth.WithLoginEmail(auth.LoginEmailOptions{
|
||||
Enabled: cfg.LoginEmailEnable,
|
||||
RequireSetup: cfg.LoginEmailRequireSetup,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue