feat: sync configurable OTP delivery providers
This commit is contained in:
parent
c18f773701
commit
6af61f26ba
28 changed files with 2100 additions and 118 deletions
16
.env.example
16
.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=
|
||||
|
|
|
|||
49
cmd/otpwebhook-example/README.md
Normal file
49
cmd/otpwebhook-example/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# OTP Webhook example
|
||||
|
||||
This command implements the `telesrv` OTP Webhook v1 receiving side with only
|
||||
the Go standard library. It validates the signed request, rejects expired or
|
||||
invalid payloads, and deduplicates concurrent or repeated delivery IDs.
|
||||
|
||||
The default `exampleDelivery` function deliberately does **not** send a real
|
||||
email/SMS and does not print the code or recipient. For local debugging only,
|
||||
set `TELESRV_OTP_EXAMPLE_LOG_CODE=true` to print the received code while still
|
||||
redacting the recipient. Replace that one function with the API call for your
|
||||
email or SMS provider before real use.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_OTP_EXAMPLE_SECRET = 'replace-with-a-random-secret'
|
||||
$env:TELESRV_OTP_EXAMPLE_LOG_CODE = 'true' # local testing only
|
||||
go run ./cmd/otpwebhook-example
|
||||
```
|
||||
|
||||
The default endpoints are:
|
||||
|
||||
- `POST http://127.0.0.1:2800/v1/otp/deliveries`
|
||||
- `GET http://127.0.0.1:2800/healthz`
|
||||
|
||||
Then configure `telesrv` with the same secret:
|
||||
|
||||
```dotenv
|
||||
TELESRV_EMAIL_CODE_DELIVERY_PROVIDER=webhook
|
||||
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=webhook
|
||||
TELESRV_OTP_WEBHOOK_URL=http://127.0.0.1:2800/v1/otp/deliveries
|
||||
TELESRV_OTP_WEBHOOK_SECRET=replace-with-a-random-secret
|
||||
```
|
||||
|
||||
The example accepts these optional settings:
|
||||
|
||||
```dotenv
|
||||
TELESRV_OTP_EXAMPLE_ADDR=127.0.0.1:2800
|
||||
TELESRV_OTP_EXAMPLE_MAX_SKEW=5m
|
||||
TELESRV_OTP_EXAMPLE_LOG_CODE=false
|
||||
```
|
||||
|
||||
The idempotency registry is intentionally in memory. A production receiver
|
||||
must put delivery IDs and the downstream provider message ID in durable shared
|
||||
storage before running more than one instance or surviving restarts. Pass the
|
||||
same delivery ID to a downstream provider when it supports idempotency. The
|
||||
example remembers both successful and failed outcomes until the code expires,
|
||||
because an apparent downstream failure may have happened after it sent the
|
||||
message.
|
||||
397
cmd/otpwebhook-example/main.go
Normal file
397
cmd/otpwebhook-example/main.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxRequestBody = 64 << 10
|
||||
|
||||
var errIdempotencyConflict = errors.New("idempotency key was already used with a different payload")
|
||||
|
||||
type config struct {
|
||||
address string
|
||||
secret string
|
||||
maxSkew time.Duration
|
||||
logCode bool
|
||||
}
|
||||
|
||||
type deliveryRequest struct {
|
||||
Version string `json:"version"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
Channel string `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryFunc func(context.Context, deliveryRequest) (string, error)
|
||||
|
||||
type receipt struct {
|
||||
fingerprint [sha256.Size]byte
|
||||
expiresAt time.Time
|
||||
done chan struct{}
|
||||
messageID string
|
||||
err error
|
||||
completed bool
|
||||
}
|
||||
|
||||
type application struct {
|
||||
secret []byte
|
||||
maxSkew time.Duration
|
||||
now func() time.Time
|
||||
deliver deliveryFunc
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
receipts map[string]*receipt
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("OTP webhook example stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
app := newApplication(cfg.secret, cfg.maxSkew, time.Now, exampleDelivery(logger, cfg.logCode), logger)
|
||||
server := &http.Server{
|
||||
Addr: cfg.address,
|
||||
Handler: app.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if cfg.secret == "" {
|
||||
logger.Warn("signature verification is disabled; set TELESRV_OTP_EXAMPLE_SECRET outside local development")
|
||||
}
|
||||
if cfg.logCode {
|
||||
logger.Warn("OTP code logging is enabled for local testing")
|
||||
}
|
||||
logger.Info("OTP webhook example listening", "address", cfg.address)
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown HTTP server: %w", err)
|
||||
}
|
||||
err := <-serverErr
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
cfg := config{
|
||||
address: envOrDefault("TELESRV_OTP_EXAMPLE_ADDR", "127.0.0.1:2800"),
|
||||
secret: os.Getenv("TELESRV_OTP_EXAMPLE_SECRET"),
|
||||
maxSkew: 5 * time.Minute,
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_MAX_SKEW")); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_MAX_SKEW must be a positive duration")
|
||||
}
|
||||
cfg.maxSkew = parsed
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TELESRV_OTP_EXAMPLE_LOG_CODE")); raw != "" {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("TELESRV_OTP_EXAMPLE_LOG_CODE must be a boolean")
|
||||
}
|
||||
cfg.logCode = parsed
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOrDefault(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func newApplication(
|
||||
secret string,
|
||||
maxSkew time.Duration,
|
||||
now func() time.Time,
|
||||
deliver deliveryFunc,
|
||||
logger *slog.Logger,
|
||||
) *application {
|
||||
return &application{
|
||||
secret: []byte(secret),
|
||||
maxSkew: maxSkew,
|
||||
now: now,
|
||||
deliver: deliver,
|
||||
logger: logger,
|
||||
receipts: make(map[string]*receipt),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
mux.HandleFunc("POST /v1/otp/deliveries", a.handleDelivery)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *application) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "CONTENT_TYPE_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBody))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", false)
|
||||
return
|
||||
}
|
||||
if err := a.verifySignature(r.Header, body); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "SIGNATURE_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
var request deliveryRequest
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
|
||||
return
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "JSON_INVALID", false)
|
||||
return
|
||||
}
|
||||
if err := validateRequest(request, r.Header.Get("Idempotency-Key"), a.now()); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "REQUEST_INVALID", false)
|
||||
return
|
||||
}
|
||||
|
||||
messageID, err := a.deliverOnce(r.Context(), request, body)
|
||||
if errors.Is(err, errIdempotencyConflict) {
|
||||
writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", false)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("OTP delivery failed", "delivery_id", request.DeliveryID)
|
||||
writeError(w, http.StatusBadGateway, "DELIVERY_FAILED", true)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, deliveryResponse{Accepted: true, MessageID: messageID})
|
||||
}
|
||||
|
||||
func (a *application) verifySignature(header http.Header, body []byte) error {
|
||||
if len(a.secret) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timestamp := header.Get("X-Telesrv-Timestamp")
|
||||
unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("invalid timestamp")
|
||||
}
|
||||
delta := a.now().Sub(time.Unix(unixSeconds, 0))
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > a.maxSkew {
|
||||
return errors.New("timestamp outside allowed skew")
|
||||
}
|
||||
|
||||
provided := header.Get("X-Telesrv-Signature")
|
||||
expected := signatureFor(a.secret, timestamp, body)
|
||||
if !hmac.Equal([]byte(provided), []byte(expected)) {
|
||||
return errors.New("signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signatureFor(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = io.WriteString(mac, timestamp)
|
||||
_, _ = mac.Write([]byte{'.'})
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func validateRequest(request deliveryRequest, idempotencyKey string, now time.Time) error {
|
||||
if request.Version != "1" {
|
||||
return errors.New("unsupported version")
|
||||
}
|
||||
if request.DeliveryID == "" || len(request.DeliveryID) > 128 || request.DeliveryID != idempotencyKey {
|
||||
return errors.New("invalid delivery ID")
|
||||
}
|
||||
if len(request.Recipient) == 0 || len(request.Recipient) > 512 {
|
||||
return errors.New("invalid recipient")
|
||||
}
|
||||
if len(request.Code) == 0 || len(request.Code) > 32 {
|
||||
return errors.New("invalid code")
|
||||
}
|
||||
if len(request.Locale) > 64 || request.ExpiresIn < 0 || request.ExpiresAt.IsZero() || !request.ExpiresAt.After(now) {
|
||||
return errors.New("invalid expiry or locale")
|
||||
}
|
||||
|
||||
expectedChannel, ok := map[string]string{
|
||||
"login_email": "email",
|
||||
"login_email_setup": "email",
|
||||
"login_email_change": "email",
|
||||
"login_sms": "sms",
|
||||
"change_phone": "sms",
|
||||
}[request.Purpose]
|
||||
if !ok || request.Channel != expectedChannel {
|
||||
return errors.New("invalid purpose or channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *application) deliverOnce(
|
||||
ctx context.Context,
|
||||
request deliveryRequest,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
fingerprint := sha256.Sum256(body)
|
||||
now := a.now()
|
||||
|
||||
a.mu.Lock()
|
||||
for id, existing := range a.receipts {
|
||||
if existing.completed && !existing.expiresAt.After(now) {
|
||||
delete(a.receipts, id)
|
||||
}
|
||||
}
|
||||
if existing, ok := a.receipts[request.DeliveryID]; ok {
|
||||
if existing.fingerprint != fingerprint {
|
||||
a.mu.Unlock()
|
||||
return "", errIdempotencyConflict
|
||||
}
|
||||
done := existing.done
|
||||
a.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
a.mu.Lock()
|
||||
messageID, err := existing.messageID, existing.err
|
||||
a.mu.Unlock()
|
||||
return messageID, err
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
current := &receipt{
|
||||
fingerprint: fingerprint,
|
||||
expiresAt: request.ExpiresAt,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
a.receipts[request.DeliveryID] = current
|
||||
a.mu.Unlock()
|
||||
|
||||
messageID, err := a.deliver(ctx, request)
|
||||
|
||||
a.mu.Lock()
|
||||
current.messageID = messageID
|
||||
current.err = err
|
||||
current.completed = true
|
||||
close(current.done)
|
||||
a.mu.Unlock()
|
||||
return messageID, err
|
||||
}
|
||||
|
||||
// exampleDelivery is the extension point for an email/SMS provider. It does
|
||||
// not send a real message. Replace this function with a provider call before
|
||||
// real use. Code logging is an explicit local-debug option.
|
||||
func exampleDelivery(logger *slog.Logger, logCode bool) deliveryFunc {
|
||||
return func(_ context.Context, request deliveryRequest) (string, error) {
|
||||
recipientHash := sha256.Sum256([]byte(request.Recipient))
|
||||
messageHash := sha256.Sum256([]byte(request.DeliveryID))
|
||||
attributes := []any{
|
||||
"delivery_id", request.DeliveryID,
|
||||
"purpose", request.Purpose,
|
||||
"channel", request.Channel,
|
||||
"recipient_sha256", hex.EncodeToString(recipientHash[:6]),
|
||||
}
|
||||
if logCode {
|
||||
attributes = append(attributes, "code", request.Code)
|
||||
}
|
||||
logger.Info("OTP delivery accepted by example adapter", attributes...)
|
||||
return "example_" + hex.EncodeToString(messageHash[:8]), nil
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string, retryable bool) {
|
||||
writeJSON(w, status, deliveryResponse{Accepted: false, ErrorCode: code, Retryable: &retryable})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, response deliveryResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
164
cmd/otpwebhook-example/main_test.go
Normal file
164
cmd/otpwebhook-example/main_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeliveryAcceptsSignedRequestAndDeduplicatesReplay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
var calls atomic.Int32
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
calls.Add(1)
|
||||
return "provider-message-1", nil
|
||||
})
|
||||
body := marshalRequest(t, validRequest(now))
|
||||
|
||||
for range 2 {
|
||||
response := performDelivery(t, app.routes(), body, "otp_test_1", "test-secret", now)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var result deliveryResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Accepted || result.MessageID != "provider-message-1" {
|
||||
t.Fatalf("unexpected response: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("deliver calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsIdempotencyConflict(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
return "provider-message-1", nil
|
||||
})
|
||||
first := validRequest(now)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, first), first.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("first status = %d", response.Code)
|
||||
}
|
||||
|
||||
second := first
|
||||
second.Code = "654321"
|
||||
response = performDelivery(t, app.routes(), marshalRequest(t, second), second.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsInvalidSignature(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
t.Fatal("deliver must not be called")
|
||||
return "", nil
|
||||
})
|
||||
request := validRequest(now)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "wrong-secret", now)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsExpiredCode(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
t.Fatal("deliver must not be called")
|
||||
return "", nil
|
||||
})
|
||||
request := validRequest(now)
|
||||
request.ExpiresAt = now.Add(-time.Second)
|
||||
response := performDelivery(t, app.routes(), marshalRequest(t, request), request.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryFailureIsAlsoDeduplicated(t *testing.T) {
|
||||
now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||
var calls atomic.Int32
|
||||
app := testApplication(now, func(_ context.Context, _ deliveryRequest) (string, error) {
|
||||
calls.Add(1)
|
||||
return "", errors.New("downstream outcome unknown")
|
||||
})
|
||||
request := validRequest(now)
|
||||
body := marshalRequest(t, request)
|
||||
|
||||
for range 2 {
|
||||
response := performDelivery(t, app.routes(), body, request.DeliveryID, "test-secret", now)
|
||||
if response.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("deliver calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testApplication(now time.Time, deliver deliveryFunc) *application {
|
||||
return newApplication(
|
||||
"test-secret",
|
||||
5*time.Minute,
|
||||
func() time.Time { return now },
|
||||
deliver,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
}
|
||||
|
||||
func validRequest(now time.Time) deliveryRequest {
|
||||
return deliveryRequest{
|
||||
Version: "1",
|
||||
DeliveryID: "otp_test_1",
|
||||
Purpose: "login_email",
|
||||
Channel: "email",
|
||||
Recipient: "alice@example.test",
|
||||
Code: "123456",
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
ExpiresIn: 300,
|
||||
Locale: "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
func marshalRequest(t *testing.T, request deliveryRequest) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func performDelivery(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
body []byte,
|
||||
deliveryID string,
|
||||
secret string,
|
||||
now time.Time,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
timestampText := strconv.FormatInt(now.Unix(), 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/otp/deliveries", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Idempotency-Key", deliveryID)
|
||||
request.Header.Set("X-Telesrv-Timestamp", timestampText)
|
||||
request.Header.Set("X-Telesrv-Signature", signatureFor([]byte(secret), timestampText, body))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// Command telesrv 是基于 iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
// Command telesrv 是基于 github.com/iamxvbaba/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -55,8 +55,10 @@ import (
|
|||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
mailpkg "telesrv/internal/mail"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/sfu"
|
||||
|
|
@ -524,18 +526,45 @@ func run(logger *zap.Logger) error {
|
|||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
}
|
||||
var loginEmailSender mailpkg.Sender
|
||||
if cfg.LoginEmailEnable {
|
||||
loginEmailSender = mailpkg.NewSMTP(mailpkg.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
var webhookSender otpdelivery.Sender
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" ||
|
||||
(cfg.LoginEmailEnable && cfg.EmailCodeDeliveryProvider == "webhook") {
|
||||
configured, err := otpwebhook.New(otpwebhook.Config{
|
||||
URL: cfg.OTPWebhookURL,
|
||||
Secret: cfg.OTPWebhookSecret,
|
||||
Timeout: cfg.OTPWebhookTimeout,
|
||||
Logger: logger.Named("otp").Named("webhook"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure OTP webhook: %w", err)
|
||||
}
|
||||
webhookSender = configured
|
||||
logger.Info("OTP Webhook 投递已启用",
|
||||
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
|
||||
zap.Bool("email", cfg.LoginEmailEnable && cfg.EmailCodeDeliveryProvider == "webhook"))
|
||||
}
|
||||
var phoneCodeSender otpdelivery.Sender
|
||||
if cfg.PhoneCodeDeliveryProvider == "webhook" {
|
||||
phoneCodeSender = webhookSender
|
||||
accountOptions = append(accountOptions, account.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength))
|
||||
}
|
||||
var loginEmailSender otpdelivery.Sender
|
||||
if cfg.LoginEmailEnable {
|
||||
switch cfg.EmailCodeDeliveryProvider {
|
||||
case "webhook":
|
||||
loginEmailSender = webhookSender
|
||||
default:
|
||||
loginEmailSender = otpsmtp.New(otpsmtp.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
FromName: cfg.SMTPFromName,
|
||||
TLSMode: cfg.SMTPTLSMode,
|
||||
Timeout: cfg.SMTPTimeout,
|
||||
})
|
||||
}
|
||||
accountOptions = append(accountOptions,
|
||||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
|
|
@ -693,6 +722,14 @@ func run(logger *zap.Logger) error {
|
|||
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
|
||||
auth.WithCodeTTL(cfg.AuthCodeTTL),
|
||||
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts),
|
||||
auth.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength),
|
||||
auth.WithOTPDeliveryFailureObserver(func(_ context.Context, request otpdelivery.Request, err error) {
|
||||
logger.Named("otp").Warn("附加 OTP provider 投递失败,777000 App-code 保持有效",
|
||||
zap.String("delivery_id", request.DeliveryID),
|
||||
zap.String("purpose", string(request.Purpose)),
|
||||
zap.String("channel", string(request.Channel)),
|
||||
zap.Error(err))
|
||||
}),
|
||||
auth.WithLoginEmail(auth.LoginEmailOptions{
|
||||
Enabled: cfg.LoginEmailEnable,
|
||||
RequireSetup: cfg.LoginEmailRequireSetup,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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,原始
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
163
internal/app/auth/otp_delivery_test.go
Normal file
163
internal/app/auth/otp_delivery_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) == "" {
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
129
internal/otpdelivery/delivery.go
Normal file
129
internal/otpdelivery/delivery.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Package otpdelivery defines the outbound boundary for one-time-code
|
||||
// delivery. Code generation, persistence and verification stay in the app
|
||||
// services; implementations in this package only deliver an already-issued
|
||||
// code through a concrete channel.
|
||||
package otpdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelEmail Channel = "email"
|
||||
ChannelSMS Channel = "sms"
|
||||
)
|
||||
|
||||
type Purpose string
|
||||
|
||||
const (
|
||||
PurposeLoginEmail Purpose = "login_email"
|
||||
PurposeLoginSMS Purpose = "login_sms"
|
||||
PurposeLoginEmailSetup Purpose = "login_email_setup"
|
||||
PurposeLoginEmailChange Purpose = "login_email_change"
|
||||
PurposeChangePhone Purpose = "change_phone"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
DeliveryID string
|
||||
Purpose Purpose
|
||||
Channel Channel
|
||||
Recipient string
|
||||
Code string
|
||||
ExpiresAt time.Time
|
||||
Locale string
|
||||
}
|
||||
|
||||
func (r Request) Validate(now time.Time) error {
|
||||
if strings.TrimSpace(r.DeliveryID) == "" || len(r.DeliveryID) > 128 {
|
||||
return fmt.Errorf("delivery id is empty or too long")
|
||||
}
|
||||
switch r.Purpose {
|
||||
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone:
|
||||
default:
|
||||
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
||||
}
|
||||
switch r.Channel {
|
||||
case ChannelEmail, ChannelSMS:
|
||||
default:
|
||||
return fmt.Errorf("unsupported delivery channel %q", r.Channel)
|
||||
}
|
||||
if strings.TrimSpace(r.Recipient) == "" || len(r.Recipient) > 512 {
|
||||
return fmt.Errorf("delivery recipient is empty or too long")
|
||||
}
|
||||
if strings.TrimSpace(r.Code) == "" || len(r.Code) > 64 {
|
||||
return fmt.Errorf("delivery code is empty or too long")
|
||||
}
|
||||
if len(r.Locale) > 32 {
|
||||
return fmt.Errorf("delivery locale is too long")
|
||||
}
|
||||
if !r.ExpiresAt.After(now) {
|
||||
return fmt.Errorf("delivery expiry is not in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ProviderMessageID string
|
||||
}
|
||||
|
||||
type Sender interface {
|
||||
Deliver(ctx context.Context, req Request) (Result, error)
|
||||
}
|
||||
|
||||
// ErrOutcomeUnknown marks a transport result for which the provider may have
|
||||
// accepted the request, but telesrv did not receive a valid acknowledgement.
|
||||
// Callers must keep the issued code usable; deleting it could invalidate a code
|
||||
// which has already reached the recipient.
|
||||
var ErrOutcomeUnknown = errors.New("otp delivery outcome unknown")
|
||||
|
||||
type OutcomeUnknownError struct {
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *OutcomeUnknownError) Error() string {
|
||||
if e == nil || e.Cause == nil {
|
||||
return ErrOutcomeUnknown.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %v", ErrOutcomeUnknown, e.Cause)
|
||||
}
|
||||
|
||||
func (e *OutcomeUnknownError) Unwrap() error {
|
||||
if e == nil || e.Cause == nil {
|
||||
return ErrOutcomeUnknown
|
||||
}
|
||||
return errors.Join(ErrOutcomeUnknown, e.Cause)
|
||||
}
|
||||
|
||||
// RejectedError is a provider acknowledgement that the request was not
|
||||
// accepted. It is safe for the caller to invalidate the corresponding code.
|
||||
type RejectedError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (e *RejectedError) Error() string {
|
||||
if e == nil {
|
||||
return "otp delivery rejected"
|
||||
}
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("otp delivery rejected: status=%d code=%s retryable=%t", e.StatusCode, e.Code, e.Retryable)
|
||||
}
|
||||
return fmt.Sprintf("otp delivery rejected: status=%d retryable=%t", e.StatusCode, e.Retryable)
|
||||
}
|
||||
|
||||
func NewDeliveryID() (string, error) {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("generate otp delivery id: %w", err)
|
||||
}
|
||||
return "otp_" + hex.EncodeToString(raw[:]), nil
|
||||
}
|
||||
36
internal/otpdelivery/delivery_test.go
Normal file
36
internal/otpdelivery/delivery_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package otpdelivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewDeliveryIDIsOpaqueAndUnique(t *testing.T) {
|
||||
first, err := NewDeliveryID()
|
||||
if err != nil {
|
||||
t.Fatalf("first id: %v", err)
|
||||
}
|
||||
second, err := NewDeliveryID()
|
||||
if err != nil {
|
||||
t.Fatalf("second id: %v", err)
|
||||
}
|
||||
if first == second || !strings.HasPrefix(first, "otp_") || len(first) != len("otp_")+32 {
|
||||
t.Fatalf("ids = %q / %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestRejectsExpiredCode(t *testing.T) {
|
||||
now := time.Now()
|
||||
err := (Request{
|
||||
DeliveryID: "otp_expired",
|
||||
Purpose: PurposeLoginEmail,
|
||||
Channel: ChannelEmail,
|
||||
Recipient: "a@example.test",
|
||||
Code: "123456",
|
||||
ExpiresAt: now,
|
||||
}).Validate(now)
|
||||
if err == nil {
|
||||
t.Fatal("expired request accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
31
internal/otpdelivery/smtp/sender_test.go
Normal file
31
internal/otpdelivery/smtp/sender_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/otpdelivery"
|
||||
)
|
||||
|
||||
func TestSenderRejectsNonEmailChannelBeforeDial(t *testing.T) {
|
||||
sender := New(Config{Host: "smtp.example.test", Port: 25, From: "noreply@example.test"})
|
||||
_, err := sender.Deliver(context.Background(), otpdelivery.Request{
|
||||
DeliveryID: "otp_sms",
|
||||
Purpose: otpdelivery.PurposeLoginSMS,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: "15550001001",
|
||||
Code: "12345",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot deliver") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanTTLRoundsNetworkSkew(t *testing.T) {
|
||||
if got := humanTTL(5*time.Minute - 200*time.Millisecond); got != "5 minutes" {
|
||||
t.Fatalf("humanTTL = %q", got)
|
||||
}
|
||||
}
|
||||
201
internal/otpdelivery/webhook/sender.go
Normal file
201
internal/otpdelivery/webhook/sender.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/otpdelivery"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolVersion = "1"
|
||||
maxResponseBytes = 64 << 10
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
Client *http.Client
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
endpoint *url.URL
|
||||
secret []byte
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Sender, error) {
|
||||
endpoint, err := url.Parse(strings.TrimSpace(cfg.URL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse OTP webhook URL: %w", err)
|
||||
}
|
||||
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
|
||||
return nil, fmt.Errorf("OTP webhook URL scheme must be http or https")
|
||||
}
|
||||
if endpoint.Host == "" || endpoint.User != nil {
|
||||
return nil, fmt.Errorf("OTP webhook URL must contain a host and no userinfo")
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
client := cfg.Client
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Sender{endpoint: endpoint, secret: []byte(cfg.Secret), client: client, logger: logger}, nil
|
||||
}
|
||||
|
||||
type requestBody struct {
|
||||
Version string `json:"version"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
Purpose otpdelivery.Purpose `json:"purpose"`
|
||||
Channel otpdelivery.Channel `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type responseBody struct {
|
||||
Accepted *bool `json:"accepted"`
|
||||
MessageID string `json:"message_id"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
func (s *Sender) Deliver(ctx context.Context, delivery otpdelivery.Request) (otpdelivery.Result, error) {
|
||||
now := time.Now()
|
||||
if err := delivery.Validate(now); err != nil {
|
||||
return otpdelivery.Result{}, err
|
||||
}
|
||||
expiresIn := int64(delivery.ExpiresAt.Sub(now) / time.Second)
|
||||
if expiresIn < 1 {
|
||||
expiresIn = 1
|
||||
}
|
||||
body, err := json.Marshal(requestBody{
|
||||
Version: protocolVersion,
|
||||
DeliveryID: delivery.DeliveryID,
|
||||
Purpose: delivery.Purpose,
|
||||
Channel: delivery.Channel,
|
||||
Recipient: delivery.Recipient,
|
||||
Code: delivery.Code,
|
||||
ExpiresAt: delivery.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
ExpiresIn: expiresIn,
|
||||
Locale: delivery.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
return otpdelivery.Result{}, fmt.Errorf("encode OTP webhook request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return otpdelivery.Result{}, fmt.Errorf("create OTP webhook request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Idempotency-Key", delivery.DeliveryID)
|
||||
timestamp := fmt.Sprint(now.Unix())
|
||||
req.Header.Set("X-Telesrv-Timestamp", timestamp)
|
||||
if len(s.secret) > 0 {
|
||||
req.Header.Set("X-Telesrv-Signature", signature(s.secret, timestamp, body))
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.Warn("OTP webhook delivery outcome is unknown",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.String("purpose", string(delivery.Purpose)),
|
||||
zap.String("channel", string(delivery.Channel)),
|
||||
zap.Error(err))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
||||
if readErr != nil || len(payload) > maxResponseBytes {
|
||||
cause := readErr
|
||||
if cause == nil {
|
||||
cause = fmt.Errorf("response exceeds %d bytes", maxResponseBytes)
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
s.logger.Warn("OTP webhook acknowledgement is unreadable",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Error(cause))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
|
||||
}
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{StatusCode: resp.StatusCode, Code: "RESPONSE_UNREADABLE", Retryable: resp.StatusCode >= 500}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
provider := decodeResponse(payload)
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: provider.ErrorCode,
|
||||
Retryable: provider.Retryable || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500,
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return otpdelivery.Result{}, nil
|
||||
}
|
||||
provider := decodeResponse(payload)
|
||||
if provider.Accepted == nil {
|
||||
cause := fmt.Errorf("2xx response is missing accepted")
|
||||
s.logger.Warn("OTP webhook acknowledgement is invalid",
|
||||
zap.String("delivery_id", delivery.DeliveryID),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Error(cause))
|
||||
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
|
||||
}
|
||||
if !*provider.Accepted {
|
||||
return otpdelivery.Result{}, &otpdelivery.RejectedError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: provider.ErrorCode,
|
||||
Retryable: provider.Retryable,
|
||||
}
|
||||
}
|
||||
return otpdelivery.Result{ProviderMessageID: provider.MessageID}, nil
|
||||
}
|
||||
|
||||
func decodeResponse(payload []byte) responseBody {
|
||||
var result responseBody
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
return result
|
||||
}
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return responseBody{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func signature(secret []byte, timestamp string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("."))
|
||||
_, _ = mac.Write(body)
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
165
internal/otpdelivery/webhook/sender_test.go
Normal file
165
internal/otpdelivery/webhook/sender_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/otpdelivery"
|
||||
)
|
||||
|
||||
func TestDeliverSendsVersionedSignedRequest(t *testing.T) {
|
||||
secret := "webhook-secret"
|
||||
var got requestBody
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost || r.Header.Get("Idempotency-Key") != "otp_test_delivery" {
|
||||
t.Errorf("method/idempotency = %s/%q", r.Method, r.Header.Get("Idempotency-Key"))
|
||||
}
|
||||
timestamp := r.Header.Get("X-Telesrv-Timestamp")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
wantSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if r.Header.Get("X-Telesrv-Signature") != wantSignature {
|
||||
t.Errorf("signature = %q, want %q", r.Header.Get("X-Telesrv-Signature"), wantSignature)
|
||||
}
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"accepted":true,"message_id":"provider-42"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender, err := New(Config{URL: server.URL, Secret: secret, Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
expiresAt := time.Now().Add(5 * time.Minute).UTC()
|
||||
result, err := sender.Deliver(context.Background(), otpdelivery.Request{
|
||||
DeliveryID: "otp_test_delivery",
|
||||
Purpose: otpdelivery.PurposeLoginEmail,
|
||||
Channel: otpdelivery.ChannelEmail,
|
||||
Recipient: "alice@example.test",
|
||||
Code: "482913",
|
||||
ExpiresAt: expiresAt,
|
||||
Locale: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Deliver: %v", err)
|
||||
}
|
||||
if result.ProviderMessageID != "provider-42" {
|
||||
t.Fatalf("message id = %q", result.ProviderMessageID)
|
||||
}
|
||||
if got.Version != protocolVersion || got.DeliveryID != "otp_test_delivery" ||
|
||||
got.Purpose != otpdelivery.PurposeLoginEmail || got.Channel != otpdelivery.ChannelEmail ||
|
||||
got.Recipient != "alice@example.test" || got.Code != "482913" || got.Locale != "zh-CN" || got.ExpiresIn < 298 {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverExplicitRejectionIsDefinite(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, `{"accepted":false,"error_code":"RATE_LIMITED","retryable":true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
var rejected *otpdelivery.RejectedError
|
||||
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTooManyRequests || rejected.Code != "RATE_LIMITED" || !rejected.Retryable {
|
||||
t.Fatalf("rejection = %#v err=%v", rejected, err)
|
||||
}
|
||||
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
t.Fatalf("explicit rejection marked unknown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverMalformedSuccessIsOutcomeUnknown(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
|
||||
t.Fatalf("err = %v, want unknown outcome", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverTransportFailureIsOutcomeUnknown(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection reset after write")
|
||||
})}
|
||||
sender, err := New(Config{URL: "https://otp.example.test/send", Client: client})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) || !strings.Contains(err.Error(), "connection reset") {
|
||||
t.Fatalf("err = %v, want unknown transport outcome", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverDoesNotFollowRedirect(t *testing.T) {
|
||||
targetCalled := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
targetCalled = true
|
||||
}))
|
||||
defer target.Close()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target.URL)
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := New(Config{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = sender.Deliver(context.Background(), validRequest())
|
||||
var rejected *otpdelivery.RejectedError
|
||||
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("err = %v, want redirect rejection", err)
|
||||
}
|
||||
if targetCalled {
|
||||
t.Fatal("redirect target received OTP")
|
||||
}
|
||||
}
|
||||
|
||||
func validRequest() otpdelivery.Request {
|
||||
return otpdelivery.Request{
|
||||
DeliveryID: "otp_valid",
|
||||
Purpose: otpdelivery.PurposeLoginSMS,
|
||||
Channel: otpdelivery.ChannelSMS,
|
||||
Recipient: "15550001001",
|
||||
Code: "12345",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue