diff --git a/.env.example b/.env.example index c88c2dc4..12d4e047 100644 --- a/.env.example +++ b/.env.example @@ -44,12 +44,24 @@ TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES=536870912 # Concurrent encrypted wire/codec/obfuscation scratch (shared bounded pool, not per connection). TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES=536870912 -# Optional login-email verification. When enabled, accounts with a confirmed -# login email receive login codes by email; REQUIRE_SETUP also forces new/legacy +# OTP delivery routing. "development" preserves the fixed phone code. "webhook" +# generates a random SMS code and sends it with the versioned protocol documented +# in docs/otp-delivery.md. For an existing account, both modes first create the +# durable 777000 message; Webhook is an additional delivery channel for that code. +TELESRV_PHONE_CODE_DELIVERY_PROVIDER=development +TELESRV_PHONE_CODE_LENGTH=5 +TELESRV_OTP_WEBHOOK_URL= +TELESRV_OTP_WEBHOOK_SECRET= +TELESRV_OTP_WEBHOOK_TIMEOUT=5s + +# Optional login-email verification. EMAIL_CODE_DELIVERY_PROVIDER may be smtp +# or webhook. Existing-account login codes are also mirrored into 777000 before +# provider delivery; setup/change codes are provider-only. REQUIRE_SETUP forces # accounts without a login email to set one during the phone login flow. TELESRV_LOGIN_EMAIL_ENABLE=false TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=false TELESRV_LOGIN_EMAIL_CODE_LENGTH=6 +TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=smtp TELESRV_SMTP_HOST= TELESRV_SMTP_PORT=587 TELESRV_SMTP_USERNAME= diff --git a/cmd/otpwebhook-example/README.md b/cmd/otpwebhook-example/README.md new file mode 100644 index 00000000..de5a09dc --- /dev/null +++ b/cmd/otpwebhook-example/README.md @@ -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. diff --git a/cmd/otpwebhook-example/main.go b/cmd/otpwebhook-example/main.go new file mode 100644 index 00000000..12a8f604 --- /dev/null +++ b/cmd/otpwebhook-example/main.go @@ -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) +} diff --git a/cmd/otpwebhook-example/main_test.go b/cmd/otpwebhook-example/main_test.go new file mode 100644 index 00000000..0064fcfa --- /dev/null +++ b/cmd/otpwebhook-example/main_test.go @@ -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 +} diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 12e56e68..8140ceec 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -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, diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 9cbaf73c..03ed03c2 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -79,27 +79,33 @@ This document describes every setting loaded by `internal/config`. Defaults and | `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. | | `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. | -## 5. Authentication, login email, SMTP, and passkeys +## 5. Authentication, OTP providers, SMTP, and passkeys | Setting | Type / code default | Description and constraints | |---|---|---| -| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | Fixed development login code. Production SMS/risk delivery is not implemented; do not expose this default publicly. | +| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | Fixed code used by `PHONE_CODE_DELIVERY_PROVIDER=development`; do not expose this default publicly. | | `TELESRV_AUTH_CODE_TTL` | duration / `5m` | Login/registration/email verification code lifetime; must be positive. | | `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | Maximum wrong attempts for one code/hash; must be positive. | +| `TELESRV_PHONE_CODE_LENGTH` | int / `5` | Random SMS-code length for the `webhook` phone provider; allowed range `4..10`. | | `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | Code issuance limit per normalized phone digest per rate window; `<=0` disables this dimension. | | `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | Code issuance limit per raw auth key per rate window; `<=0` disables this dimension. | | `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. | -| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification delivery. When true, SMTP settings below become mandatory. | +| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. | +| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Must use `http`/`https` and contain no userinfo. | +| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / empty | Optional HMAC-SHA256 signing secret; enables `X-Telesrv-Signature` when non-empty. | +| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP timeout; must be positive when Webhook delivery is enabled. | +| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification. SMTP settings are required only when the email provider is `smtp`. | | `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | Forces accounts without a login email to configure one. Requires `TELESRV_LOGIN_EMAIL_ENABLE=true`. | | `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | Email verification-code length; allowed range `4..10`. | -| `TELESRV_SMTP_HOST` | string / empty | SMTP server host; required when login email is enabled. | -| `TELESRV_SMTP_PORT` | int / `587` | SMTP port; must be `1..65535` when login email is enabled. | +| `TELESRV_SMTP_HOST` | string / empty | SMTP server host; required when login email is enabled with the `smtp` provider. | +| `TELESRV_SMTP_PORT` | int / `587` | SMTP port; must be `1..65535` when the SMTP provider is used. | | `TELESRV_SMTP_USERNAME` | sensitive string / empty | SMTP username. Also used as sender when `TELESRV_SMTP_FROM` is empty. | | `TELESRV_SMTP_PASSWORD` | secret string / empty | SMTP password. | | `TELESRV_SMTP_FROM` | email/string / empty | Envelope/header sender. Either this or SMTP username is required when login email is enabled. | | `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | Display name for login-email messages. | | `TELESRV_SMTP_TLS` | enum / `starttls` | `starttls`, `tls`, or `none`; any other value fails startup. | -| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP operation timeout; must be positive when login email is enabled. | +| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP operation timeout; must be positive when the SMTP provider is used. | | `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID used for `rpIdHash`. Android Credential Manager requires alignment with hosted `assetlinks.json`. | | `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / empty | Allowed WebAuthn origins. Empty disables explicit origin enforcement because Android APK-key-hash origins may not be known in advance. | @@ -232,4 +238,4 @@ The following fallback keys are accepted from the **process environment only**. ## 12. Production minimum checklist -At minimum, production operators should explicitly review and override the development credentials/endpoints: PostgreSQL DSN and TLS, Redis password/network exposure, RSA key persistence, fixed development auth code exposure, Admin credentials/session key, SMTP secrets when enabled, AI/Mapbox API keys, TURN secret and firewall ports, public URLs/scheme alignment, and non-loopback SFU/TURN advertise addresses for real devices. +At minimum, production operators should explicitly review and override the development credentials/endpoints: PostgreSQL DSN and TLS, Redis password/network exposure, RSA key persistence, fixed development auth code exposure, Admin credentials/session key, OTP Webhook/SMTP secrets, AI/Mapbox API keys, TURN secret and firewall ports, public URLs/scheme alignment, and non-loopback SFU/TURN advertise addresses for real devices. diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 01265d36..fe8a8e0c 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -79,27 +79,33 @@ | `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 | | `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 | -## 5. 登录、邮箱验证码、SMTP 与 passkey +## 5. 登录、OTP Provider、SMTP 与 passkey | 参数 | 类型 / 代码默认值 | 说明与约束 | |---|---|---| -| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | 固定开发登录码;生产短信/风控尚未接入,不得把默认值暴露在公网环境。 | +| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | `PHONE_CODE_DELIVERY_PROVIDER=development` 使用的固定开发登录码;不得把默认值暴露在公网环境。 | | `TELESRV_AUTH_CODE_TTL` | duration / `5m` | 登录/注册/邮箱验证码有效期,必须为正数。 | | `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | 单 code/hash 最大错误次数,必须为正数。 | +| `TELESRV_PHONE_CODE_LENGTH` | int / `5` | `webhook` phone provider 生成的随机 SMS 验证码长度,允许 `4..10`。 | | `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | 每个规范化手机号摘要在窗口内的发码上限;`<=0` 关闭该维度。 | | `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | 每个 raw auth key 在窗口内的发码上限;`<=0` 关闭该维度。 | | `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 | -| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码投递;开启后 SMTP 配置成为必填。 | +| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息,Webhook 只是附加渠道。 | +| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp` 或 `webhook`。已有账号的登录邮箱码会先同码镜像到 777000;邮箱 setup/change 仍只走 provider。 | +| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。只允许 `http`/`https` 且不得含 userinfo。 | +| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / 空 | 可选 HMAC-SHA256 签名密钥;非空时发送 `X-Telesrv-Signature`。 | +| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP 请求超时,启用 Webhook 时必须为正数。 | +| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码;email provider 为 `smtp` 时要求 SMTP 配置,`webhook` 时不依赖 SMTP。 | | `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | 强制没有登录邮箱的账号设置邮箱;要求 `TELESRV_LOGIN_EMAIL_ENABLE=true`。 | | `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | 邮箱验证码长度,允许 `4..10`。 | -| `TELESRV_SMTP_HOST` | string / 空 | SMTP host;启用登录邮箱时必填。 | -| `TELESRV_SMTP_PORT` | int / `587` | SMTP 端口;启用登录邮箱时必须为 `1..65535`。 | +| `TELESRV_SMTP_HOST` | string / 空 | SMTP host;启用登录邮箱且 email provider 为 `smtp` 时必填。 | +| `TELESRV_SMTP_PORT` | int / `587` | SMTP 端口;使用 SMTP provider 时必须为 `1..65535`。 | | `TELESRV_SMTP_USERNAME` | sensitive string / 空 | SMTP 用户名;`TELESRV_SMTP_FROM` 为空时也用作发件人。 | | `TELESRV_SMTP_PASSWORD` | secret string / 空 | SMTP 密码。 | | `TELESRV_SMTP_FROM` | email/string / 空 | envelope/header 发件人;启用登录邮箱时它与 SMTP username 至少一个非空。 | | `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | 登录邮件展示的发件人名称。 | | `TELESRV_SMTP_TLS` | enum / `starttls` | 仅允许 `starttls`、`tls`、`none`,其它值阻止启动。 | -| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP 操作超时;启用登录邮箱时必须为正数。 | +| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP 操作超时;使用 SMTP provider 时必须为正数。 | | `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID,用于校验 `rpIdHash`;Android Credential Manager 必须与公网 `assetlinks.json` 对齐。 | | `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / 空 | WebAuthn origin 白名单;空值不做显式 origin 校验,因为服务端可能无法预知 Android APK-key-hash origin。 | @@ -232,4 +238,4 @@ ## 12. 生产部署最低检查清单 -生产至少应显式检查并替换这些开发值:PostgreSQL DSN 与 TLS、Redis 密码和网络暴露、RSA 私钥持久化、固定开发验证码暴露、Admin 凭证/session key、启用邮件时的 SMTP secret、AI/Mapbox API key、TURN secret 与防火墙端口、公开 URL/scheme 与客户端一致性,以及真机所需的非 loopback SFU/TURN advertise IP。 +生产至少应显式检查并替换这些开发值:PostgreSQL DSN 与 TLS、Redis 密码和网络暴露、RSA 私钥持久化、固定开发验证码暴露、Admin 凭证/session key、OTP Webhook/SMTP secret、AI/Mapbox API key、TURN secret 与防火墙端口、公开 URL/scheme 与客户端一致性,以及真机所需的非 loopback SFU/TURN advertise IP。 diff --git a/internal/app/account/login_email_test.go b/internal/app/account/login_email_test.go index 5871a469..6dd0c544 100644 --- a/internal/app/account/login_email_test.go +++ b/internal/app/account/login_email_test.go @@ -9,6 +9,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/otpdelivery" "telesrv/internal/store" "telesrv/internal/store/memory" ) @@ -30,8 +31,10 @@ func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User } type captureMailSender struct { - to string - code string + to string + code string + requests []otpdelivery.Request + err error } type blockingCodeCAS struct { @@ -116,10 +119,102 @@ func (s *blockingCodeCAS) CompareAndDelete(ctx context.Context, key, revision st return s.CodeStore.CompareAndDelete(ctx, key, revision) } -func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error { - s.to = to - s.code = code - return nil +func (s *captureMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) { + s.to = req.Recipient + s.code = req.Code + s.requests = append(s.requests, req) + return otpdelivery.Result{}, s.err +} + +func TestLoginEmailDeliveryCarriesPurposeAndStableID(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + codes := memory.NewCodeStore() + sender := &captureMailSender{} + svc := NewService(memory.NewPasswordStore(), + WithUsers(users), + WithLoginEmailVerification(codes, sender, time.Minute, 3, 6)) + u := createUser(t, users, "15550010150") + + pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "Alice@Example.Test", false) + if err != nil { + t.Fatalf("SendLoginEmailCode: %v", err) + } + if pattern == "" || length != 6 || len(sender.requests) != 1 { + t.Fatalf("pattern=%q length=%d requests=%d", pattern, length, len(sender.requests)) + } + req := sender.requests[0] + if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginEmailChange || req.Channel != otpdelivery.ChannelEmail || + req.Recipient != "alice@example.test" || len(req.Code) != 6 { + t.Fatalf("request = %+v", req) + } + snapshot, found, err := codes.GetSnapshot(ctx, loginEmailVerifyChangePrefix+fmt.Sprint(u.ID)) + if err != nil || !found || snapshot.Record.DeliveryID != req.DeliveryID || snapshot.Record.Code != req.Code { + t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err) + } +} + +func TestLoginEmailSetupDeliveryUsesSetupPurpose(t *testing.T) { + ctx := context.Background() + codes := memory.NewCodeStore() + sender := &captureMailSender{} + phone := "15550010151" + phoneHash := "setup-purpose-hash" + if err := codes.Set(ctx, phoneHash, store.PhoneCode{ + Version: store.PhoneCodeVersionCurrent, + Phone: phone, + Channel: codeChannelEmailSetupRequired, + }, time.Minute); err != nil { + t.Fatalf("seed setup code: %v", err) + } + svc := NewService(memory.NewPasswordStore(), + WithUsers(memory.NewUserStore()), + WithLoginEmailVerification(codes, sender, time.Minute, 3, 6)) + + if _, _, err := svc.SendLoginEmailCode(ctx, 0, phone, phoneHash, "new@example.test", true); err != nil { + t.Fatalf("SendLoginEmailCode setup: %v", err) + } + if len(sender.requests) != 1 || sender.requests[0].Purpose != otpdelivery.PurposeLoginEmailSetup { + t.Fatalf("requests = %+v", sender.requests) + } +} + +func TestLoginEmailExplicitRejectionDeletesOnlyCurrentAttempt(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + codes := memory.NewCodeStore() + sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 400, Code: "RECIPIENT_INVALID"}} + svc := NewService(memory.NewPasswordStore(), + WithUsers(users), + WithLoginEmailVerification(codes, sender, time.Minute, 3, 6)) + u := createUser(t, users, "15550010152") + key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID) + + if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "bad@example.test", false); err == nil { + t.Fatal("explicit rejection succeeded") + } + if _, found, err := codes.Get(ctx, key); err != nil || found { + t.Fatalf("rejected code found=%v err=%v", found, err) + } +} + +func TestLoginEmailUnknownOutcomeReturnsSuccessAndKeepsCode(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + codes := memory.NewCodeStore() + sender := &captureMailSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("ack lost")}} + svc := NewService(memory.NewPasswordStore(), + WithUsers(users), + WithLoginEmailVerification(codes, sender, time.Minute, 3, 6)) + u := createUser(t, users, "15550010153") + + if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "unknown@example.test", false); err != nil { + t.Fatalf("unknown outcome: %v", err) + } + key := loginEmailVerifyChangePrefix + fmt.Sprint(u.ID) + if rec, found, err := codes.Get(ctx, key); err != nil || !found || rec.Code != sender.code { + t.Fatalf("unknown code=%+v found=%v err=%v", rec, found, err) + } } // TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始 diff --git a/internal/app/account/phone_change.go b/internal/app/account/phone_change.go index f69015dc..1cad99dd 100644 --- a/internal/app/account/phone_change.go +++ b/internal/app/account/phone_change.go @@ -4,11 +4,13 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "fmt" "strings" "time" "telesrv/internal/domain" + "telesrv/internal/otpdelivery" "telesrv/internal/store" ) @@ -42,27 +44,60 @@ func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKey } else if found && existing.ID != 0 { return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied } - if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" { + if s.codes == nil || (s.phoneCodeSender == nil && strings.TrimSpace(s.phoneChangeCode) == "") { return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured") } hash, err := phoneChangeHash() if err != nil { return "", domain.AuthCodeDelivery{}, err } + code := s.phoneChangeCode + channel := store.PhoneCodeChannelPhone + deliveryID := "" + if s.phoneCodeSender != nil { + code, err = randomDigits(s.phoneCodeLength) + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + deliveryID, err = otpdelivery.NewDeliveryID() + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + channel = store.PhoneCodeChannelSMS + } rec := store.PhoneCode{ Version: store.PhoneCodeVersionCurrent, Phone: phone, - Code: s.phoneChangeCode, - Channel: store.PhoneCodeChannelPhone, + Code: code, + DeliveryID: deliveryID, + Channel: channel, Purpose: store.PhoneCodePurposeChangePhone, UserID: userID, AuthKeyID: authKeyID, SessionID: sessionID, MaxAttempts: s.phoneChangeMaxAttempts, } + expiresAt := time.Now().Add(s.phoneChangeCodeTTL) if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil { return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err) } + if s.phoneCodeSender != nil { + if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: otpdelivery.PurposeChangePhone, + Channel: otpdelivery.ChannelSMS, + Recipient: phone, + Code: code, + ExpiresAt: expiresAt, + }); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil { + return "", domain.AuthCodeDelivery{}, errors.Join(err, fmt.Errorf("rollback phone change code: %w", cleanupErr)) + } + return "", domain.AuthCodeDelivery{}, err + } + } return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil } @@ -107,7 +142,8 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied } consumed := verified.Record - if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || consumed.Channel != store.PhoneCodeChannelPhone { + if consumed.Version != store.PhoneCodeVersionCurrent || consumed.Scope() != scope || + (consumed.Channel != store.PhoneCodeChannelPhone && consumed.Channel != store.PhoneCodeChannelSMS) { return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid } if date == 0 { diff --git a/internal/app/account/phone_change_test.go b/internal/app/account/phone_change_test.go index f541e267..d6a47c57 100644 --- a/internal/app/account/phone_change_test.go +++ b/internal/app/account/phone_change_test.go @@ -8,6 +8,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/otpdelivery" "telesrv/internal/store" "telesrv/internal/store/memory" ) @@ -30,6 +31,16 @@ type recordingPhoneChangeStore struct { last domain.PhoneChangeRequest } +type trackingPhoneCodeStore struct { + store.CodeStore + lastHash string +} + +func (s *trackingPhoneCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { + s.lastHash = hash + return s.CodeStore.Set(ctx, hash, code, ttl) +} + func (s *recordingPhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) { s.mu.Lock() s.last = req @@ -67,6 +78,53 @@ func newPhoneChangeFixture(t *testing.T) phoneChangeFixture { return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID, changes: changes} } +func TestPhoneChangeWebhookDeliversRandomScopedCode(t *testing.T) { + f := newPhoneChangeFixture(t) + sender := &captureMailSender{} + f.service.phoneCodeSender = sender + f.service.phoneCodeLength = 6 + f.service.phoneChangeCode = "" + + hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012020") + if err != nil { + t.Fatalf("SendChangePhoneCode: %v", err) + } + if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 || len(sender.requests) != 1 { + t.Fatalf("hash=%q delivery=%+v requests=%d", hash, delivery, len(sender.requests)) + } + req := sender.requests[0] + if req.Purpose != otpdelivery.PurposeChangePhone || req.Channel != otpdelivery.ChannelSMS || req.Recipient != "15550012020" || req.DeliveryID == "" { + t.Fatalf("request = %+v", req) + } + rec, found, err := f.codes.Get(f.ctx, hash) + if err != nil || !found || rec.Channel != store.PhoneCodeChannelSMS || rec.DeliveryID != req.DeliveryID || rec.Code != req.Code { + t.Fatalf("record=%+v found=%v err=%v", rec, found, err) + } + if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, f.authKeyID, 78, req.Recipient, hash, req.Code, 1700000000); err != nil { + t.Fatalf("ChangePhone: %v", err) + } +} + +func TestPhoneChangeWebhookRejectionRevokesScopedCode(t *testing.T) { + f := newPhoneChangeFixture(t) + sender := &captureMailSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}} + tracked := &trackingPhoneCodeStore{CodeStore: f.codes} + f.service.codes = tracked + f.service.phoneCodeSender = sender + f.service.phoneCodeLength = 5 + + hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012021") + if hash != "" || err == nil || len(sender.requests) != 1 { + t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests)) + } + if tracked.lastHash == "" { + t.Fatal("code was not stored before delivery") + } + if rec, found, getErr := f.codes.Get(f.ctx, tracked.lastHash); getErr != nil || found || rec.Code != "" { + t.Fatalf("post-rejection code rec=%+v found=%v err=%v", rec, found, getErr) + } +} + func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) { f := newPhoneChangeFixture(t) hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002") diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 78ba8a2a..05da630a 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -4,13 +4,14 @@ import ( "context" "crypto/rand" "crypto/subtle" + "errors" "fmt" "strings" "time" "telesrv/internal/domain" "telesrv/internal/links" - "telesrv/internal/mail" + "telesrv/internal/otpdelivery" "telesrv/internal/store" ) @@ -47,7 +48,9 @@ type Service struct { phoneChangeCode string phoneChangeCodeTTL time.Duration phoneChangeMaxAttempts int - loginEmailSender mail.Sender + loginEmailSender otpdelivery.Sender + phoneCodeSender otpdelivery.Sender + phoneCodeLength int loginEmailCodeTTL time.Duration loginEmailCodeMaxAttempts int loginEmailCodeLength int @@ -136,7 +139,7 @@ func WithPublicBaseURL(baseURL string) ServiceOption { } } -func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption { +func WithLoginEmailVerification(codes store.CodeStore, sender otpdelivery.Sender, ttl time.Duration, maxAttempts, length int) ServiceOption { return func(s *Service) { s.codes = codes s.loginEmailSender = sender @@ -152,6 +155,17 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t } } +// WithPhoneCodeDelivery replaces the fixed development code used by the +// change-phone flow with an externally delivered SMS code. +func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption { + return func(s *Service) { + s.phoneCodeSender = sender + if length > 0 { + s.phoneCodeLength = length + } + } +} + // NewService 创建 account 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{ @@ -162,6 +176,7 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { loginEmailCodeLength: 6, phoneChangeCodeTTL: 5 * time.Minute, phoneChangeMaxAttempts: 5, + phoneCodeLength: 5, } for _, opt := range opts { opt(s) @@ -616,18 +631,54 @@ func (s *Service) SendLoginEmailCode(ctx context.Context, userID int64, phone, p return "", 0, err } rec.Code = code + deliveryID, err := otpdelivery.NewDeliveryID() + if err != nil { + return "", 0, err + } + rec.DeliveryID = deliveryID + expiresAt := time.Now().Add(s.loginEmailCodeTTL) if err := s.codes.Set(ctx, key, rec, s.loginEmailCodeTTL); err != nil { return "", 0, err } - if err := s.loginEmailSender.SendLoginCode(ctx, email, code, s.loginEmailCodeTTL); err != nil { - // Set does not expose its generated revision. A blind Del here could - // remove a newer concurrent resend; leave the unreachable random code - // to expire or be replaced by the retry instead. + snapshot, found, err := s.codes.GetSnapshot(ctx, key) + if err != nil { + return "", 0, err + } + if !found || snapshot.Record.DeliveryID != deliveryID { + return "", 0, domain.ErrEmailCodeInvalid + } + purpose := otpdelivery.PurposeLoginEmailChange + if setup { + purpose = otpdelivery.PurposeLoginEmailSetup + } + if err := deliverOTP(ctx, s.loginEmailSender, otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: purpose, + Channel: otpdelivery.ChannelEmail, + Recipient: email, + Code: code, + ExpiresAt: expiresAt, + }); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + deleted, cleanupErr := s.codes.CompareAndDelete(cleanupCtx, key, snapshot.Revision) + if cleanupErr != nil { + return "", 0, fmt.Errorf("%w; rollback email code: %v", err, cleanupErr) + } + _ = deleted // false means a newer concurrent resend owns the key. return "", 0, err } return emailPattern(email), len(code), nil } +func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error { + _, err := sender.Deliver(ctx, req) + if errors.Is(err, otpdelivery.ErrOutcomeUnknown) { + return nil + } + return err +} + func (s *Service) VerifyLoginEmail(ctx context.Context, userID int64, phone, phoneCodeHash, code string, setup bool) (string, error) { if s == nil || s.codes == nil { return "", domain.ErrEmailNotAllowed diff --git a/internal/app/auth/login_code_delivery_test.go b/internal/app/auth/login_code_delivery_test.go index 21666bfb..1d07e727 100644 --- a/internal/app/auth/login_code_delivery_test.go +++ b/internal/app/auth/login_code_delivery_test.go @@ -7,6 +7,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/otpdelivery" "telesrv/internal/store" "telesrv/internal/store/memory" ) @@ -340,7 +341,7 @@ func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) { } } -func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) { +func TestConfiguredEmailLoginMirrorsSameCodeThroughAppDelivery(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil { @@ -360,7 +361,40 @@ func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) { if mailSender.to != "secure@example.test" || mailSender.code == "" { t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code) } - if len(delivery.requests) != 0 { - t.Fatalf("email code leaked into app delivery: %+v", delivery.requests) + if len(delivery.requests) != 1 || delivery.requests[0].Code != mailSender.code || delivery.requests[0].PhoneCodeHash == "" { + t.Fatalf("email App-code delivery=%+v, want same code and non-empty hash", delivery.requests) + } +} + +func TestConfiguredEmailLoginProviderFailureKeepsDurableAppCode(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + user, err := users.Create(ctx, domain.User{Phone: "15550009215"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + emails := &testLoginEmailStore{emails: map[string]string{user.Phone: "fallback@example.test"}} + codes := memory.NewCodeStore() + mailSender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}} + delivery := &captureLoginCodeDelivery{} + var observed []error + svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", + WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}), + WithLoginCodeDelivery(delivery), + WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) { + observed = append(observed, err) + }), + ) + + hash, err := svc.SendCode(ctx, user.Phone) + if err != nil || hash == "" { + t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err) + } + if len(delivery.requests) != 1 || len(mailSender.requests) != 1 || len(observed) != 1 || + delivery.requests[0].Code != mailSender.requests[0].Code { + t.Fatalf("App=%+v provider=%+v observed=%d", delivery.requests, mailSender.requests, len(observed)) + } + if rec, found, getErr := codes.Get(ctx, hash); getErr != nil || !found || rec.Code != delivery.requests[0].Code { + t.Fatalf("code=%+v found=%v err=%v", rec, found, getErr) } } diff --git a/internal/app/auth/login_email_config_test.go b/internal/app/auth/login_email_config_test.go index 9b9271aa..585d9cb5 100644 --- a/internal/app/auth/login_email_config_test.go +++ b/internal/app/auth/login_email_config_test.go @@ -4,9 +4,9 @@ import ( "context" "errors" "testing" - "time" "telesrv/internal/domain" + "telesrv/internal/otpdelivery" "telesrv/internal/store/memory" ) @@ -28,10 +28,10 @@ type testMailSender struct { code string } -func (s *testMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error { - s.to = to - s.code = code - return nil +func (s *testMailSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) { + s.to = req.Recipient + s.code = req.Code + return otpdelivery.Result{}, nil } func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) { @@ -43,7 +43,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) { } emails := &testLoginEmailStore{emails: map[string]string{"15550009101": "alice@example.test"}} sender := &testMailSender{} + appDelivery := &captureLoginCodeDelivery{} svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345", + WithLoginCodeDelivery(appDelivery), WithLoginEmail(LoginEmailOptions{ Enabled: true, CodeLength: 6, @@ -59,6 +61,9 @@ func TestConfiguredEmailLoginSendsAndLimitsAttempts(t *testing.T) { if sender.to != "alice@example.test" || len(sender.code) != 6 { t.Fatalf("sent email to/code = %q/%q, want alice@example.test/6 digits", sender.to, sender.code) } + if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != sender.code { + t.Fatalf("App-code delivery=%+v, want same email code/hash", appDelivery.requests) + } delivery, found, err := svc.CodeDelivery(ctx, hash) if err != nil || !found { t.Fatalf("CodeDelivery found=%v err=%v", found, err) @@ -109,9 +114,11 @@ func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) { } emails := &testLoginEmailStore{emails: map[string]string{"15550009102": "bob@example.test"}} sender := &testMailSender{} + appDelivery := &captureLoginCodeDelivery{} var key [8]byte key[0] = 0x91 svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345", + WithLoginCodeDelivery(appDelivery), WithLoginEmail(LoginEmailOptions{ Enabled: true, CodeLength: 5, @@ -123,6 +130,9 @@ func TestConfiguredEmailLoginAcceptsCorrectCode(t *testing.T) { if err != nil { t.Fatalf("SendCode: %v", err) } + if len(appDelivery.requests) != 1 || appDelivery.requests[0].Code != sender.code { + t.Fatalf("App-code delivery=%+v, want same email code", appDelivery.requests) + } got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009102", hash, sender.code) if err != nil { t.Fatalf("SignInWithEmail: %v", err) diff --git a/internal/app/auth/otp_delivery_test.go b/internal/app/auth/otp_delivery_test.go new file mode 100644 index 00000000..360d5eb8 --- /dev/null +++ b/internal/app/auth/otp_delivery_test.go @@ -0,0 +1,163 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/otpdelivery" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +type captureOTPSender struct { + requests []otpdelivery.Request + err error + before func() +} + +func (s *captureOTPSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) { + if s.before != nil { + s.before() + } + s.requests = append(s.requests, req) + return otpdelivery.Result{ProviderMessageID: "capture-message"}, s.err +} + +func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + user, err := users.Create(ctx, domain.User{Phone: "15550009301", FirstName: "Webhook"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + codes := memory.NewCodeStore() + appDelivery := &captureLoginCodeDelivery{} + sender := &captureOTPSender{before: func() { + if len(appDelivery.requests) != 1 { + t.Fatalf("provider called before durable App-code: requests=%d", len(appDelivery.requests)) + } + }} + svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "fixed-code-must-not-leak", + WithLoginCodeDelivery(appDelivery), + WithPhoneCodeDelivery(sender, 6)) + + hash, err := svc.SendCode(ctx, "+1 555 000 9301") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + if hash == "" || len(sender.requests) != 1 { + t.Fatalf("hash=%q requests=%d", hash, len(sender.requests)) + } + req := sender.requests[0] + if req.DeliveryID == "" || req.Purpose != otpdelivery.PurposeLoginSMS || req.Channel != otpdelivery.ChannelSMS || + req.Recipient != "15550009301" || len(req.Code) != 6 || req.Code == "fixed-code-must-not-leak" || time.Until(req.ExpiresAt) < 4*time.Minute { + t.Fatalf("request = %+v", req) + } + if len(appDelivery.requests) != 1 || appDelivery.requests[0].PhoneCodeHash != hash || appDelivery.requests[0].Code != req.Code { + t.Fatalf("App-code delivery=%+v, want same hash/code as provider", appDelivery.requests) + } + rec, found, err := codes.Get(ctx, hash) + if err != nil || !found || rec.Code != req.Code || rec.DeliveryID != req.DeliveryID || rec.Channel != store.PhoneCodeChannelSMS { + t.Fatalf("stored code=%+v found=%v err=%v", rec, found, err) + } + delivery, found, err := svc.CodeDelivery(ctx, hash) + if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 6 { + t.Fatalf("delivery=%+v found=%v err=%v", delivery, found, err) + } + got, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{3}}, req.Recipient, hash, req.Code) + if err != nil || needSignUp || got.ID != user.ID { + t.Fatalf("SignIn user=%+v needSignUp=%v err=%v", got, needSignUp, err) + } +} + +func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + user, err := users.Create(ctx, domain.User{Phone: "15550009305", FirstName: "Fallback"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + codes := memory.NewCodeStore() + appDelivery := &captureLoginCodeDelivery{} + sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}} + var observed []error + svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", + WithLoginCodeDelivery(appDelivery), + WithPhoneCodeDelivery(sender, 6), + WithOTPDeliveryFailureObserver(func(_ context.Context, _ otpdelivery.Request, err error) { + observed = append(observed, err) + }), + ) + + hash, err := svc.SendCode(ctx, user.Phone) + if err != nil || hash == "" { + t.Fatalf("SendCode hash=%q err=%v, want App fallback success", hash, err) + } + if len(sender.requests) != 1 || len(appDelivery.requests) != 1 || len(observed) != 1 { + t.Fatalf("provider=%d App=%d observed=%d, want 1/1/1", len(sender.requests), len(appDelivery.requests), len(observed)) + } + rec, found, err := codes.Get(ctx, hash) + if err != nil || !found || rec.Code != appDelivery.requests[0].Code || rec.Code != sender.requests[0].Code { + t.Fatalf("code=%+v found=%v err=%v App=%+v provider=%+v", rec, found, err, appDelivery.requests, sender.requests) + } +} + +func TestWebhookPhoneLoginExplicitRejectionRollsBackCode(t *testing.T) { + ctx := context.Background() + baseCodes := memory.NewCodeStore() + codes := &trackingCodeStore{CodeStore: baseCodes} + sender := &captureOTPSender{err: &otpdelivery.RejectedError{StatusCode: 503, Code: "UNAVAILABLE", Retryable: true}} + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", + WithPhoneCodeDelivery(sender, 5)) + + hash, err := svc.SendCode(ctx, "15550009302") + if hash != "" || err == nil || len(sender.requests) != 1 || codes.lastSetHash == "" { + t.Fatalf("hash=%q err=%v requests=%d set=%q", hash, err, len(sender.requests), codes.lastSetHash) + } + if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found { + t.Fatalf("rejected code found=%v err=%v", found, getErr) + } +} + +func TestWebhookPhoneLoginUnknownOutcomeKeepsUsableCode(t *testing.T) { + ctx := context.Background() + codes := memory.NewCodeStore() + sender := &captureOTPSender{err: &otpdelivery.OutcomeUnknownError{Cause: errors.New("response lost")}} + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", + WithPhoneCodeDelivery(sender, 5)) + + hash, err := svc.SendCode(ctx, "15550009303") + if err != nil || hash == "" || len(sender.requests) != 1 { + t.Fatalf("hash=%q err=%v requests=%d", hash, err, len(sender.requests)) + } + rec, found, err := codes.Get(ctx, hash) + if err != nil || !found || rec.Code != sender.requests[0].Code { + t.Fatalf("unknown outcome code=%+v found=%v err=%v", rec, found, err) + } +} + +func TestWebhookPhoneResendRotatesCodeAndDeliveryID(t *testing.T) { + ctx := context.Background() + sender := &captureOTPSender{} + codes := memory.NewCodeStore() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", + WithPhoneCodeDelivery(sender, 6)) + firstHash, err := svc.SendCode(ctx, "15550009304") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + secondHash, err := svc.ResendCode(ctx, "15550009304", firstHash) + if err != nil { + t.Fatalf("ResendCode: %v", err) + } + if firstHash == secondHash || len(sender.requests) != 2 || + sender.requests[0].DeliveryID == sender.requests[1].DeliveryID { + t.Fatalf("hashes=%q/%q requests=%+v", firstHash, secondHash, sender.requests) + } + if _, found, err := codes.Get(ctx, firstHash); err != nil || found { + t.Fatalf("old code found=%v err=%v", found, err) + } +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index f689450b..38c7d3fd 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -18,7 +18,7 @@ import ( mtcrypto "github.com/iamxvbaba/td/crypto" "telesrv/internal/domain" - "telesrv/internal/mail" + "telesrv/internal/otpdelivery" "telesrv/internal/store" ) @@ -48,9 +48,10 @@ var ( ) const ( - codeChannelPhone = "phone" - codeChannelEmailLogin = "email_login" - codeChannelEmailSetupRequired = "email_setup_required" + codeChannelPhone = store.PhoneCodeChannelPhone + codeChannelSMS = store.PhoneCodeChannelSMS + codeChannelEmailLogin = store.PhoneCodeChannelEmailLogin + codeChannelEmailSetupRequired = store.PhoneCodeChannelEmailSetupRequired loginCodeRollbackTimeout = 2 * time.Second ) @@ -70,7 +71,9 @@ func systemLoginPhoneForbidden(phone string) bool { return ok } -// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。 +// Service 实现登录/注册业务。默认保留开发固定码;配置外部 provider +// 后生成随机验证码并通过 otpdelivery 投递。已有账号的外部投递是 durable +// 777000 App-code 的附加渠道,不能替换或削弱原有消息事实。 type Service struct { users store.UserStore auths store.AuthorizationStore @@ -86,7 +89,10 @@ type Service struct { codeTTL time.Duration codeMaxAttempts int loginEmails loginEmailStore - loginEmailSender mail.Sender + loginEmailSender otpdelivery.Sender + phoneCodeSender otpdelivery.Sender + otpDeliveryFailure func(context.Context, otpdelivery.Request, error) + phoneCodeLength int loginEmailEnabled bool loginEmailRequireSetup bool loginEmailCodeLength int @@ -104,7 +110,7 @@ type LoginEmailOptions struct { RequireSetup bool CodeLength int Store loginEmailStore - Sender mail.Sender + Sender otpdelivery.Sender } type authorizationRevoker interface { @@ -185,9 +191,31 @@ func WithLoginEmail(opts LoginEmailOptions) Option { } } +// WithPhoneCodeDelivery enables an external SMS delivery provider. Existing +// accounts keep their durable 777000 App-code and receive the same code through +// the provider as an additional channel. A nil sender preserves development +// behavior. +func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) Option { + return func(s *Service) { + s.phoneCodeSender = sender + if length > 0 { + s.phoneCodeLength = length + } + } +} + +// WithOTPDeliveryFailureObserver observes failures of an additional provider +// delivery after an existing account already has a durable 777000 App-code. +// Observers must not log the recipient or code. +func WithOTPDeliveryFailureObserver(observer func(context.Context, otpdelivery.Request, error)) Option { + return func(s *Service) { + s.otpDeliveryFailure = observer + } +} + // NewService 创建登录服务。fixedCode 为开发固定验证码。 func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service { - s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6} + s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6, phoneCodeLength: 5} if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil { linker.LinkAuthKeyAuthority(authKeys) } @@ -372,32 +400,68 @@ func (s *Service) createPhoneCode(ctx context.Context, phone string, existingUse if err != nil { return "", err } - if err := s.codes.Set(ctx, hash, store.PhoneCode{ + code := s.fixedCode + channel := codeChannelPhone + deliveryID := "" + if s.phoneCodeSender != nil { + code, err = randomDigits(s.phoneCodeLength) + if err != nil { + return "", err + } + deliveryID, err = otpdelivery.NewDeliveryID() + if err != nil { + return "", err + } + channel = codeChannelSMS + } + rec := store.PhoneCode{ Version: store.PhoneCodeVersionCurrent, IssuedUserID: existingUserID, Phone: phone, - Code: s.fixedCode, - Channel: codeChannelPhone, + Code: code, + DeliveryID: deliveryID, + Channel: channel, MaxAttempts: s.codeMaxAttempts, - }, s.codeTTL); err != nil { + } + expiresAt := time.Now().Add(s.codeTTL) + if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil { return "", fmt.Errorf("store code: %w", err) } - rec := store.PhoneCode{Phone: phone, IssuedUserID: existingUserID} if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { return "", err } - // 新手机号还没有 owner/dialog,只能在 SignUp 创建用户后写第一条 - // 777000 消息。已有账号则必须在 sendCode RPC 返回前把 app-code - // 作为普通 incoming message + durable update/outbox 提交;登录成功不再补发。 - if existingUserID == 0 { + // Existing accounts always retain the original durable App-code path. Commit + // it before attempting the external mirror so a provider cannot replace the + // message fact or leave an externally disclosed code without local state. + if existingUserID != 0 { + if err := s.deliverLoginCode(ctx, existingUserID, hash, code); err != nil { + return "", s.rollbackUndeliveredCode(ctx, hash, err) + } + if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { + return "", err + } + } + if s.phoneCodeSender != nil { + request := otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: otpdelivery.PurposeLoginSMS, + Channel: otpdelivery.ChannelSMS, + Recipient: phone, + Code: code, + ExpiresAt: expiresAt, + } + if existingUserID != 0 { + s.deliverOTPWithAppFallback(ctx, s.phoneCodeSender, request) + } else if err := deliverOTP(ctx, s.phoneCodeSender, request); err != nil { + return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login SMS code: %w", err)) + } + if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { + return "", err + } return hash, nil } - if err := s.deliverLoginCode(ctx, existingUserID, hash, s.fixedCode); err != nil { - return "", s.rollbackUndeliveredCode(ctx, hash, err) - } - if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { - return "", err - } + // 新手机号还没有 owner/dialog,不能在签发阶段创建 777000 消息; + // 已有账号的 App-code 已在上面的 provider 分支之前 durable 提交。 return hash, nil } @@ -463,25 +527,60 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string, if err != nil { return "", err } + deliveryID, err := otpdelivery.NewDeliveryID() + if err != nil { + return "", err + } rec := store.PhoneCode{ Version: store.PhoneCodeVersionCurrent, IssuedUserID: issuedUserID, Phone: phone, Code: code, + DeliveryID: deliveryID, Channel: codeChannelEmailLogin, Email: strings.TrimSpace(email), MaxAttempts: s.codeMaxAttempts, } + expiresAt := time.Now().Add(s.codeTTL) if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil { return "", fmt.Errorf("store email code: %w", err) } if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { return "", err } + if issuedUserID != 0 { + if err := s.deliverLoginCode(ctx, issuedUserID, hash, code); err != nil { + return "", s.rollbackUndeliveredCode(ctx, hash, err) + } + if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { + return "", err + } + } if s.loginEmailSender == nil { + if issuedUserID != 0 { + s.reportOTPDeliveryFailure(ctx, otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: otpdelivery.PurposeLoginEmail, + Channel: otpdelivery.ChannelEmail, + Recipient: rec.Email, + Code: code, + ExpiresAt: expiresAt, + }, fmt.Errorf("login email sender is not configured")) + return hash, nil + } return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("login email sender is not configured")) } - if err := s.loginEmailSender.SendLoginCode(ctx, rec.Email, code, s.codeTTL); err != nil { + request := otpdelivery.Request{ + DeliveryID: deliveryID, + Purpose: otpdelivery.PurposeLoginEmail, + Channel: otpdelivery.ChannelEmail, + Recipient: rec.Email, + Code: code, + ExpiresAt: expiresAt, + } + if issuedUserID != 0 { + s.deliverOTPWithAppFallback(ctx, s.loginEmailSender, request) + } else if err := deliverOTP(ctx, s.loginEmailSender, request); err != nil { return "", s.rollbackUndeliveredCode(ctx, hash, fmt.Errorf("send login email code: %w", err)) } if err := s.ensureIssuedOwnerAfterSet(ctx, hash, rec); err != nil { @@ -490,6 +589,34 @@ func (s *Service) createEmailLoginCode(ctx context.Context, phone, email string, return hash, nil } +// deliverOTPWithAppFallback performs an additional provider delivery only +// after the same code is durably visible through 777000. A provider failure +// must not invalidate that visible code or fail the RPC; it remains observable +// through the injected failure observer. +func (s *Service) deliverOTPWithAppFallback(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) { + if _, err := sender.Deliver(ctx, req); err != nil { + s.reportOTPDeliveryFailure(ctx, req, err) + } +} + +func (s *Service) reportOTPDeliveryFailure(ctx context.Context, req otpdelivery.Request, err error) { + if s.otpDeliveryFailure != nil && err != nil { + s.otpDeliveryFailure(ctx, req, err) + } +} + +// deliverOTP treats a transport-level unknown outcome as a successful issue: +// the provider may already have accepted the request, so the code must remain +// usable and the client needs the hash in order to verify or explicitly resend +// it. Only an explicit provider rejection is safe to roll back. +func deliverOTP(ctx context.Context, sender otpdelivery.Sender, req otpdelivery.Request) error { + _, err := sender.Deliver(ctx, req) + if errors.Is(err, otpdelivery.ErrOutcomeUnknown) { + return nil + } + return err +} + func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domain.AuthCodeDelivery, bool, error) { rec, found, err := s.codes.Get(ctx, phoneCodeHash) if err != nil || !found { @@ -503,6 +630,8 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery { return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)} } switch rec.Channel { + case codeChannelSMS: + return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)} case codeChannelEmailLogin: return domain.AuthCodeDelivery{ Kind: domain.AuthCodeDeliveryEmail, @@ -582,7 +711,7 @@ func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phon if rec.Channel == codeChannelEmailSetupRequired { return s.createSetupRequiredCode(ctx, phone, rec.IssuedUserID) } - if rec.Channel != codeChannelPhone { + if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS { return "", ErrCodeInvalid } return s.createPhoneCode(ctx, phone, rec.IssuedUserID) @@ -594,14 +723,44 @@ func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCo return "", err } rec.Code = s.fixedCode + rec.DeliveryID = "" rec.Channel = codeChannelPhone + if s.phoneCodeSender != nil { + rec.Code, err = randomDigits(s.phoneCodeLength) + if err != nil { + return "", err + } + rec.DeliveryID, err = otpdelivery.NewDeliveryID() + if err != nil { + return "", err + } + rec.Channel = codeChannelSMS + } rec.Attempts = 0 if rec.MaxAttempts <= 0 { rec.MaxAttempts = s.codeMaxAttempts } + expiresAt := time.Now().Add(s.codeTTL) if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil { return "", fmt.Errorf("store resent phone change code: %w", err) } + if s.phoneCodeSender != nil { + if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{ + DeliveryID: rec.DeliveryID, + Purpose: otpdelivery.PurposeChangePhone, + Channel: otpdelivery.ChannelSMS, + Recipient: rec.Phone, + Code: rec.Code, + ExpiresAt: expiresAt, + }); err != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeRollbackTimeout) + defer cancel() + if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil { + return "", errors.Join(err, fmt.Errorf("rollback undelivered phone change code: %w", cleanupErr)) + } + return "", err + } + } return hash, nil } @@ -785,7 +944,7 @@ func (s *Service) verifyLoginCode(ctx context.Context, phone, phoneCodeHash, cod if rec.Phone != phone || rec.Purpose != "" { return store.PhoneCode{}, domain.User{}, false, ErrCodeInvalid } - channelAllowed := rec.Channel == codeChannelPhone && !emailPath + channelAllowed := (rec.Channel == codeChannelPhone || rec.Channel == codeChannelSMS) && !emailPath if emailPath { channelAllowed = rec.Channel == codeChannelEmailLogin || (!s.loginEmailEnabled && rec.Channel == codeChannelPhone) } @@ -928,7 +1087,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, s.invalidateLoginCodeDetached(ctx, phoneCodeHash, phone) return domain.User{}, domain.Message{}, ErrCodeInvalid } - if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin { + if rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS && rec.Channel != codeChannelEmailLogin { return domain.User{}, domain.Message{}, ErrCodeInvalid } if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" { @@ -948,7 +1107,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, return domain.User{}, domain.Message{}, ErrCodeExpired } rec = consumed - if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin) { + if rec.IssuedUserID != 0 || !rec.SignUpVerified || (rec.Channel != codeChannelPhone && rec.Channel != codeChannelSMS && rec.Channel != codeChannelEmailLogin) { return domain.User{}, domain.Message{}, ErrCodeInvalid } if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil { @@ -985,8 +1144,9 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, return domain.User{}, domain.Message{}, err } loginMessage := domain.Message{} - // SMTP setup/login codes are secret factors, not 777000 app messages. Only - // the normal phone/app-code registration path creates the bootstrap dialog. + // A new account has no owner/dialog at issuance time. Only the development + // phone/App registration path creates its bootstrap 777000 message here; + // external SMS and email setup registration retain only their verified fact. if rec.Channel == codeChannelPhone { loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 73c2f5d7..702ed312 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "bufio" "fmt" + "net/url" "os" "strconv" "strings" @@ -112,6 +113,9 @@ type Config struct { DevAuthCode string // AuthCodeTTL 是登录/注册/邮箱验证 code 的有效期。 AuthCodeTTL time.Duration + // PhoneCodeLength 是使用外部 provider 时生成的短信验证码长度。development + // provider 继续使用 DevAuthCode 原样,不受此字段影响。 + PhoneCodeLength int // AuthCodeMaxAttempts 是同一 phone_code_hash / email verification code 的最大错误次数。 // 达到上限后验证码立即失效,用户必须重发。 AuthCodeMaxAttempts int @@ -127,7 +131,16 @@ type Config struct { LoginEmailRequireSetup bool // LoginEmailCodeLength 是邮箱验证码长度。 LoginEmailCodeLength int - // SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。 + // PhoneCodeDeliveryProvider 选择普通登录/注册与改号验证码的投递方式: + // development 保留固定码与 777000 app-code;webhook 使用随机 SMS code。 + PhoneCodeDeliveryProvider string + // EmailCodeDeliveryProvider 选择登录邮箱与邮箱 setup/change 的投递方式。 + EmailCodeDeliveryProvider string + // OTPWebhook* 定义固定 v1 webhook 协议的端点、HMAC secret 与请求超时。 + OTPWebhookURL string + OTPWebhookSecret string + OTPWebhookTimeout time.Duration + // SMTP* 是 email provider=smtp 时使用的出站邮件配置。 SMTPHost string SMTPPort int SMTPUsername string @@ -456,6 +469,7 @@ func Load() (Config, error) { DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"), AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute), + PhoneCodeLength: envIntOr("TELESRV_PHONE_CODE_LENGTH", 5), AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5), AuthCodePhoneRateLimit: envIntOr("TELESRV_AUTH_CODE_PHONE_RATE_LIMIT", 5), AuthCodeAuthKeyRateLimit: envIntOr("TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT", 20), @@ -463,6 +477,11 @@ func Load() (Config, error) { LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false), LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false), LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6), + PhoneCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "development"))), + EmailCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "smtp"))), + OTPWebhookURL: envOr("TELESRV_OTP_WEBHOOK_URL", ""), + OTPWebhookSecret: envOr("TELESRV_OTP_WEBHOOK_SECRET", ""), + OTPWebhookTimeout: envDurationOr("TELESRV_OTP_WEBHOOK_TIMEOUT", 5*time.Second), SMTPHost: envOr("TELESRV_SMTP_HOST", ""), SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587), SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""), @@ -619,6 +638,9 @@ func validateLoginEmailConfig(cfg Config) error { if cfg.AuthCodeMaxAttempts <= 0 { return fmt.Errorf("TELESRV_AUTH_CODE_MAX_ATTEMPTS must be positive") } + if cfg.PhoneCodeLength < 4 || cfg.PhoneCodeLength > 10 { + return fmt.Errorf("TELESRV_PHONE_CODE_LENGTH must be between 4 and 10") + } if cfg.LoginEmailCodeLength < 4 || cfg.LoginEmailCodeLength > 10 { return fmt.Errorf("TELESRV_LOGIN_EMAIL_CODE_LENGTH must be between 4 and 10") } @@ -627,7 +649,28 @@ func validateLoginEmailConfig(cfg Config) error { default: return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none") } - if !cfg.LoginEmailEnable { + switch cfg.PhoneCodeDeliveryProvider { + case "development", "webhook": + default: + return fmt.Errorf("TELESRV_PHONE_CODE_DELIVERY_PROVIDER must be development or webhook") + } + switch cfg.EmailCodeDeliveryProvider { + case "smtp", "webhook": + default: + return fmt.Errorf("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER must be smtp or webhook") + } + webhookEnabled := cfg.PhoneCodeDeliveryProvider == "webhook" || + (cfg.LoginEmailEnable && cfg.EmailCodeDeliveryProvider == "webhook") + if webhookEnabled { + if cfg.OTPWebhookTimeout <= 0 { + return fmt.Errorf("TELESRV_OTP_WEBHOOK_TIMEOUT must be positive") + } + u, err := url.Parse(strings.TrimSpace(cfg.OTPWebhookURL)) + if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("TELESRV_OTP_WEBHOOK_URL must be an absolute http(s) URL without userinfo") + } + } + if !cfg.LoginEmailEnable || cfg.EmailCodeDeliveryProvider == "webhook" { return nil } if strings.TrimSpace(cfg.SMTPHost) == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 86c5ce1c..880d5e11 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { disableDefaultConfigFile(t) - t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10") + t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.AdvertiseIP != "203.0.113.10" { + if cfg.AdvertiseIP != "192.0.2.10" { t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP) } } @@ -199,6 +199,7 @@ func TestLoadLoginEmailDefaultsDisabled(t *testing.T) { t.Fatal("LoginEmailRequireSetup = true, want false") } if cfg.AuthCodeTTL != 5*time.Minute || cfg.AuthCodeMaxAttempts != 5 || cfg.LoginEmailCodeLength != 6 || + cfg.PhoneCodeLength != 5 || cfg.PhoneCodeDeliveryProvider != "development" || cfg.EmailCodeDeliveryProvider != "smtp" || cfg.AuthCodePhoneRateLimit != 5 || cfg.AuthCodeAuthKeyRateLimit != 20 || cfg.AuthCodeRateWindow != 10*time.Minute { t.Fatalf("auth/login email defaults = ttl=%v attempts=%d length=%d phone_limit=%d key_limit=%d window=%v", cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength, @@ -251,6 +252,50 @@ func TestLoadLoginEmailRequiresSMTPWhenEnabled(t *testing.T) { } } +func TestLoadLoginEmailWebhookDoesNotRequireSMTP(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_LOGIN_EMAIL_ENABLE", "true") + t.Setenv("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "webhook") + t.Setenv("TELESRV_OTP_WEBHOOK_URL", "https://otp.example.test/v1/deliveries") + t.Setenv("TELESRV_OTP_WEBHOOK_SECRET", "test-secret") + t.Setenv("TELESRV_OTP_WEBHOOK_TIMEOUT", "3s") + t.Setenv("TELESRV_SMTP_HOST", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.EmailCodeDeliveryProvider != "webhook" || cfg.OTPWebhookURL != "https://otp.example.test/v1/deliveries" || + cfg.OTPWebhookSecret != "test-secret" || cfg.OTPWebhookTimeout != 3*time.Second { + t.Fatalf("webhook config = %#v", cfg) + } +} + +func TestLoadPhoneWebhookRequiresValidURL(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "webhook") + t.Setenv("TELESRV_OTP_WEBHOOK_URL", "relative/path") + + if _, err := Load(); err == nil { + t.Fatal("Load succeeded with relative OTP webhook URL") + } +} + +func TestLoadPhoneWebhookConfig(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "webhook") + t.Setenv("TELESRV_PHONE_CODE_LENGTH", "7") + t.Setenv("TELESRV_OTP_WEBHOOK_URL", "http://127.0.0.1:8080/otp") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.PhoneCodeDeliveryProvider != "webhook" || cfg.PhoneCodeLength != 7 { + t.Fatalf("phone webhook config = %#v", cfg) + } +} + func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) { disableDefaultConfigFile(t) t.Setenv("TELESRV_ADMIN_UI_ADDR", "") diff --git a/internal/mtprotoedge/login_email_e2e_test.go b/internal/mtprotoedge/login_email_e2e_test.go index 96e8f206..f694a327 100644 --- a/internal/mtprotoedge/login_email_e2e_test.go +++ b/internal/mtprotoedge/login_email_e2e_test.go @@ -27,6 +27,7 @@ import ( "telesrv/internal/app/help" "telesrv/internal/app/updates" "telesrv/internal/app/users" + "telesrv/internal/otpdelivery" "telesrv/internal/rpc" "telesrv/internal/store/memory" ) @@ -36,10 +37,10 @@ type loginEmailTestSender struct { code string } -func (s *loginEmailTestSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error { - s.to = to - s.code = code - return nil +func (s *loginEmailTestSender) Deliver(_ context.Context, req otpdelivery.Request) (otpdelivery.Result, error) { + s.to = req.Recipient + s.code = req.Code + return otpdelivery.Result{}, nil } // TestLoginEmailEndToEnd 端到端验证登录邮箱:设备 A 注册并设置登录邮箱(loginChange), diff --git a/internal/otpdelivery/delivery.go b/internal/otpdelivery/delivery.go new file mode 100644 index 00000000..1fed3a20 --- /dev/null +++ b/internal/otpdelivery/delivery.go @@ -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 +} diff --git a/internal/otpdelivery/delivery_test.go b/internal/otpdelivery/delivery_test.go new file mode 100644 index 00000000..6a60d813 --- /dev/null +++ b/internal/otpdelivery/delivery_test.go @@ -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") + } +} diff --git a/internal/mail/sender.go b/internal/otpdelivery/smtp/sender.go similarity index 74% rename from internal/mail/sender.go rename to internal/otpdelivery/smtp/sender.go index 3b2060ed..207d0223 100644 --- a/internal/mail/sender.go +++ b/internal/otpdelivery/smtp/sender.go @@ -1,4 +1,4 @@ -package mail +package smtp import ( "bytes" @@ -8,9 +8,11 @@ import ( "mime" "net" stdmail "net/mail" - "net/smtp" + stdsmtp "net/smtp" "strings" "time" + + "telesrv/internal/otpdelivery" ) type Config struct { @@ -24,15 +26,11 @@ type Config struct { Timeout time.Duration } -type Sender interface { - SendLoginCode(ctx context.Context, to, code string, ttl time.Duration) error -} - -type SMTP struct { +type Sender struct { cfg Config } -func NewSMTP(cfg Config) *SMTP { +func New(cfg Config) *Sender { if cfg.Timeout <= 0 { cfg.Timeout = 10 * time.Second } @@ -43,16 +41,26 @@ func NewSMTP(cfg Config) *SMTP { if strings.TrimSpace(cfg.From) == "" { cfg.From = cfg.Username } - return &SMTP{cfg: cfg} + return &Sender{cfg: cfg} } -func (s *SMTP) SendLoginCode(ctx context.Context, to, code string, ttl time.Duration) error { +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", code, humanTTL(ttl)) - return s.send(ctx, to, subject, body) + 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 *SMTP) send(ctx context.Context, to, subject, body string) error { +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") } @@ -77,15 +85,15 @@ func (s *SMTP) send(ctx context.Context, to, subject, body string) error { defer conn.Close() mode := strings.ToLower(strings.TrimSpace(s.cfg.TLSMode)) - var c *smtp.Client + 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 = smtp.NewClient(tlsConn, s.cfg.Host) + c, err = stdsmtp.NewClient(tlsConn, s.cfg.Host) } else { - c, err = smtp.NewClient(conn, s.cfg.Host) + c, err = stdsmtp.NewClient(conn, s.cfg.Host) } if err != nil { return fmt.Errorf("new smtp client: %w", err) @@ -101,7 +109,7 @@ func (s *SMTP) send(ctx context.Context, to, subject, body string) error { } } if s.cfg.Username != "" { - if err := c.Auth(smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)); err != nil { + if err := c.Auth(stdsmtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)); err != nil { return fmt.Errorf("smtp auth: %w", err) } } @@ -143,6 +151,9 @@ 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 { diff --git a/internal/otpdelivery/smtp/sender_test.go b/internal/otpdelivery/smtp/sender_test.go new file mode 100644 index 00000000..b515dd34 --- /dev/null +++ b/internal/otpdelivery/smtp/sender_test.go @@ -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) + } +} diff --git a/internal/otpdelivery/webhook/sender.go b/internal/otpdelivery/webhook/sender.go new file mode 100644 index 00000000..375654fb --- /dev/null +++ b/internal/otpdelivery/webhook/sender.go @@ -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)) +} diff --git a/internal/otpdelivery/webhook/sender_test.go b/internal/otpdelivery/webhook/sender_test.go new file mode 100644 index 00000000..72a7a2ad --- /dev/null +++ b/internal/otpdelivery/webhook/sender_test.go @@ -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) +} diff --git a/internal/store/code.go b/internal/store/code.go index 72b34eba..09016c48 100644 --- a/internal/store/code.go +++ b/internal/store/code.go @@ -11,6 +11,7 @@ import ( const ( PhoneCodePurposeChangePhone = "change_phone" PhoneCodeChannelPhone = "phone" + PhoneCodeChannelSMS = "sms" PhoneCodeChannelEmailLogin = "email_login" PhoneCodeChannelEmailSetupRequired = "email_setup_required" ) @@ -34,8 +35,12 @@ type PhoneCode struct { SignUpVerified bool Phone string Code string - Channel string - Purpose string + // DeliveryID is the stable, opaque idempotency key used for the outbound + // provider call that carries this code. It contains no recipient or secret + // material and is rotated whenever a genuinely new code is issued. + DeliveryID string + Channel string + Purpose string // UserID is also encoded as a string because scoped verification mutates the // record in Redis Lua and must not round an int64 owner through cjson. UserID int64 `json:",string"` diff --git a/internal/store/memory/login_code.go b/internal/store/memory/login_code.go index cbef2f63..b7b7950a 100644 --- a/internal/store/memory/login_code.go +++ b/internal/store/memory/login_code.go @@ -191,7 +191,7 @@ func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) ( } func loginCodeVerifiable(record store.PhoneCode) bool { - return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin + return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelSMS || record.Channel == store.PhoneCodeChannelEmailLogin } func loginCodeTakeable(record store.PhoneCode) bool { diff --git a/internal/store/redisstore/login_code.go b/internal/store/redisstore/login_code.go index f282a609..275391d3 100644 --- a/internal/store/redisstore/login_code.go +++ b/internal/store/redisstore/login_code.go @@ -31,14 +31,14 @@ if record.SignUpVerified == true then end local channel = record.Channel or '' if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2] - or (channel ~= ARGV[6] and channel ~= ARGV[7]) + or (channel ~= ARGV[6] and channel ~= ARGV[7] and channel ~= ARGV[8]) or (record.Code or '') == '' or ARGV[3] == '' then return {1, raw} end if (record.Code or '') ~= ARGV[3] then local attempts = tonumber(record.Attempts or 0) + 1 record.Attempts = attempts - record.Revision = ARGV[8] + record.Revision = ARGV[9] local max_attempts = tonumber(record.MaxAttempts or 0) if not max_attempts or max_attempts <= 0 then max_attempts = tonumber(ARGV[5]) or 0 @@ -59,7 +59,7 @@ if ARGV[4] == '1' then return {1, raw} end record.SignUpVerified = true - record.Revision = ARGV[8] + record.Revision = ARGV[9] local updated = cjson.encode(record) redis.call('SET', KEYS[1], updated, 'KEEPTTL') return {2, updated} @@ -144,7 +144,7 @@ if record.SignUpVerified == true then end local channel = record.Channel or '' if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2] - or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then + or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5] and channel ~= ARGV[6]) then return '' end redis.call('DEL', KEYS[1]) @@ -167,7 +167,7 @@ if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then end local channel = record.Channel or '' if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2] - or (channel ~= ARGV[3] and channel ~= ARGV[4]) + or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) or tonumber(record.IssuedUserID or '0') ~= 0 or record.SignUpVerified ~= true then return '' @@ -192,7 +192,7 @@ if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then end local channel = record.Channel or '' if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2] - or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then + or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5] and channel ~= ARGV[6]) then return '' end redis.call('DEL', KEYS[1]) @@ -218,6 +218,7 @@ func (s *CodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, k keep, defaultMaxAttempts, store.PhoneCodeChannelPhone, + store.PhoneCodeChannelSMS, store.PhoneCodeChannelEmailLogin, revision, ).Result() @@ -276,6 +277,7 @@ func (s *CodeStore) ConsumeSignUpVerified(ctx context.Context, hash, phone strin true, true, store.PhoneCodeChannelPhone, + store.PhoneCodeChannelSMS, store.PhoneCodeChannelEmailLogin, ) } @@ -290,6 +292,7 @@ func (s *CodeStore) TakeLoginCode(ctx context.Context, hash, phone string) (stor false, false, store.PhoneCodeChannelPhone, + store.PhoneCodeChannelSMS, store.PhoneCodeChannelEmailLogin, store.PhoneCodeChannelEmailSetupRequired, ) @@ -305,6 +308,7 @@ func (s *CodeStore) InvalidateLoginCode(ctx context.Context, hash, phone string) false, true, store.PhoneCodeChannelPhone, + store.PhoneCodeChannelSMS, store.PhoneCodeChannelEmailLogin, store.PhoneCodeChannelEmailSetupRequired, ) diff --git a/internal/store/redisstore/login_code_integration_test.go b/internal/store/redisstore/login_code_integration_test.go index eff488bd..5fa1b03a 100644 --- a/internal/store/redisstore/login_code_integration_test.go +++ b/internal/store/redisstore/login_code_integration_test.go @@ -29,6 +29,39 @@ func TestRedisCodeStoreAtomicLoginStateMachine(t *testing.T) { } } + t.Run("sms channel participates in every login transition", func(t *testing.T) { + sms := newRecord() + sms.Channel = store.PhoneCodeChannelSMS + sms.IssuedUserID = 0 + verifyHash := hash("sms-verify") + if err := codes.Set(ctx, verifyHash, sms, time.Minute); err != nil { + t.Fatal(err) + } + result, err := codes.VerifyLogin(ctx, verifyHash, phone, sms.Code, true, 5) + if err != nil || result.Status != store.LoginCodeVerifyAccepted || !result.Record.SignUpVerified { + t.Fatalf("sms verify = %+v err=%v", result, err) + } + if consumed, found, err := codes.ConsumeSignUpVerified(ctx, verifyHash, phone); err != nil || !found || consumed.Channel != store.PhoneCodeChannelSMS { + t.Fatalf("sms signup consume=%+v found=%v err=%v", consumed, found, err) + } + + takeHash := hash("sms-take") + if err := codes.Set(ctx, takeHash, sms, time.Minute); err != nil { + t.Fatal(err) + } + if taken, found, err := codes.TakeLoginCode(ctx, takeHash, phone); err != nil || !found || taken.Channel != store.PhoneCodeChannelSMS { + t.Fatalf("sms take=%+v found=%v err=%v", taken, found, err) + } + + invalidateHash := hash("sms-invalidate") + if err := codes.Set(ctx, invalidateHash, sms, time.Minute); err != nil { + t.Fatal(err) + } + if found, err := codes.InvalidateLoginCode(ctx, invalidateHash, phone); err != nil || !found { + t.Fatalf("sms invalidate found=%v err=%v", found, err) + } + }) + t.Run("version and corrupt records fail closed", func(t *testing.T) { legacy := newRecord() legacy.Version = 0