feat: sync configurable OTP delivery providers

This commit is contained in:
A 2026-07-17 17:04:39 +08:00
parent c18f773701
commit 6af61f26ba
28 changed files with 2100 additions and 118 deletions

View file

@ -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=

View 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.

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

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

View file

@ -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,

View file

@ -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.

View file

@ -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。

View file

@ -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原始

View file

@ -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 {

View file

@ -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")

View file

@ -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

View file

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

View file

@ -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)

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

View file

@ -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 {

View file

@ -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-codewebhook 使用随机 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) == "" {

View file

@ -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", "")

View file

@ -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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -0,0 +1,201 @@
package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/otpdelivery"
)
const (
protocolVersion = "1"
maxResponseBytes = 64 << 10
)
type Config struct {
URL string
Secret string
Timeout time.Duration
Client *http.Client
Logger *zap.Logger
}
type Sender struct {
endpoint *url.URL
secret []byte
client *http.Client
logger *zap.Logger
}
func New(cfg Config) (*Sender, error) {
endpoint, err := url.Parse(strings.TrimSpace(cfg.URL))
if err != nil {
return nil, fmt.Errorf("parse OTP webhook URL: %w", err)
}
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
return nil, fmt.Errorf("OTP webhook URL scheme must be http or https")
}
if endpoint.Host == "" || endpoint.User != nil {
return nil, fmt.Errorf("OTP webhook URL must contain a host and no userinfo")
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
client := cfg.Client
if client == nil {
client = &http.Client{
Timeout: cfg.Timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
logger := cfg.Logger
if logger == nil {
logger = zap.NewNop()
}
return &Sender{endpoint: endpoint, secret: []byte(cfg.Secret), client: client, logger: logger}, nil
}
type requestBody struct {
Version string `json:"version"`
DeliveryID string `json:"delivery_id"`
Purpose otpdelivery.Purpose `json:"purpose"`
Channel otpdelivery.Channel `json:"channel"`
Recipient string `json:"recipient"`
Code string `json:"code"`
ExpiresAt string `json:"expires_at"`
ExpiresIn int64 `json:"expires_in"`
Locale string `json:"locale,omitempty"`
}
type responseBody struct {
Accepted *bool `json:"accepted"`
MessageID string `json:"message_id"`
ErrorCode string `json:"error_code"`
Retryable bool `json:"retryable"`
}
func (s *Sender) Deliver(ctx context.Context, delivery otpdelivery.Request) (otpdelivery.Result, error) {
now := time.Now()
if err := delivery.Validate(now); err != nil {
return otpdelivery.Result{}, err
}
expiresIn := int64(delivery.ExpiresAt.Sub(now) / time.Second)
if expiresIn < 1 {
expiresIn = 1
}
body, err := json.Marshal(requestBody{
Version: protocolVersion,
DeliveryID: delivery.DeliveryID,
Purpose: delivery.Purpose,
Channel: delivery.Channel,
Recipient: delivery.Recipient,
Code: delivery.Code,
ExpiresAt: delivery.ExpiresAt.UTC().Format(time.RFC3339),
ExpiresIn: expiresIn,
Locale: delivery.Locale,
})
if err != nil {
return otpdelivery.Result{}, fmt.Errorf("encode OTP webhook request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint.String(), bytes.NewReader(body))
if err != nil {
return otpdelivery.Result{}, fmt.Errorf("create OTP webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Idempotency-Key", delivery.DeliveryID)
timestamp := fmt.Sprint(now.Unix())
req.Header.Set("X-Telesrv-Timestamp", timestamp)
if len(s.secret) > 0 {
req.Header.Set("X-Telesrv-Signature", signature(s.secret, timestamp, body))
}
resp, err := s.client.Do(req)
if err != nil {
s.logger.Warn("OTP webhook delivery outcome is unknown",
zap.String("delivery_id", delivery.DeliveryID),
zap.String("purpose", string(delivery.Purpose)),
zap.String("channel", string(delivery.Channel)),
zap.Error(err))
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: err}
}
defer resp.Body.Close()
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if readErr != nil || len(payload) > maxResponseBytes {
cause := readErr
if cause == nil {
cause = fmt.Errorf("response exceeds %d bytes", maxResponseBytes)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.logger.Warn("OTP webhook acknowledgement is unreadable",
zap.String("delivery_id", delivery.DeliveryID),
zap.Int("status", resp.StatusCode),
zap.Error(cause))
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
}
return otpdelivery.Result{}, &otpdelivery.RejectedError{StatusCode: resp.StatusCode, Code: "RESPONSE_UNREADABLE", Retryable: resp.StatusCode >= 500}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
provider := decodeResponse(payload)
return otpdelivery.Result{}, &otpdelivery.RejectedError{
StatusCode: resp.StatusCode,
Code: provider.ErrorCode,
Retryable: provider.Retryable || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500,
}
}
if resp.StatusCode == http.StatusNoContent {
return otpdelivery.Result{}, nil
}
provider := decodeResponse(payload)
if provider.Accepted == nil {
cause := fmt.Errorf("2xx response is missing accepted")
s.logger.Warn("OTP webhook acknowledgement is invalid",
zap.String("delivery_id", delivery.DeliveryID),
zap.Int("status", resp.StatusCode),
zap.Error(cause))
return otpdelivery.Result{}, &otpdelivery.OutcomeUnknownError{Cause: cause}
}
if !*provider.Accepted {
return otpdelivery.Result{}, &otpdelivery.RejectedError{
StatusCode: resp.StatusCode,
Code: provider.ErrorCode,
Retryable: provider.Retryable,
}
}
return otpdelivery.Result{ProviderMessageID: provider.MessageID}, nil
}
func decodeResponse(payload []byte) responseBody {
var result responseBody
if len(bytes.TrimSpace(payload)) == 0 {
return result
}
if err := json.Unmarshal(payload, &result); err != nil {
return responseBody{}
}
return result
}
func signature(secret []byte, timestamp string, body []byte) string {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(timestamp))
_, _ = mac.Write([]byte("."))
_, _ = mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

View file

@ -0,0 +1,165 @@
package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/otpdelivery"
)
func TestDeliverSendsVersionedSignedRequest(t *testing.T) {
secret := "webhook-secret"
var got requestBody
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if r.Method != http.MethodPost || r.Header.Get("Idempotency-Key") != "otp_test_delivery" {
t.Errorf("method/idempotency = %s/%q", r.Method, r.Header.Get("Idempotency-Key"))
}
timestamp := r.Header.Get("X-Telesrv-Timestamp")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
wantSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if r.Header.Get("X-Telesrv-Signature") != wantSignature {
t.Errorf("signature = %q, want %q", r.Header.Get("X-Telesrv-Signature"), wantSignature)
}
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"accepted":true,"message_id":"provider-42"}`)
}))
defer server.Close()
sender, err := New(Config{URL: server.URL, Secret: secret, Timeout: time.Second})
if err != nil {
t.Fatalf("New: %v", err)
}
expiresAt := time.Now().Add(5 * time.Minute).UTC()
result, err := sender.Deliver(context.Background(), otpdelivery.Request{
DeliveryID: "otp_test_delivery",
Purpose: otpdelivery.PurposeLoginEmail,
Channel: otpdelivery.ChannelEmail,
Recipient: "alice@example.test",
Code: "482913",
ExpiresAt: expiresAt,
Locale: "zh-CN",
})
if err != nil {
t.Fatalf("Deliver: %v", err)
}
if result.ProviderMessageID != "provider-42" {
t.Fatalf("message id = %q", result.ProviderMessageID)
}
if got.Version != protocolVersion || got.DeliveryID != "otp_test_delivery" ||
got.Purpose != otpdelivery.PurposeLoginEmail || got.Channel != otpdelivery.ChannelEmail ||
got.Recipient != "alice@example.test" || got.Code != "482913" || got.Locale != "zh-CN" || got.ExpiresIn < 298 {
t.Fatalf("request = %+v", got)
}
}
func TestDeliverExplicitRejectionIsDefinite(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, `{"accepted":false,"error_code":"RATE_LIMITED","retryable":true}`)
}))
defer server.Close()
sender, err := New(Config{URL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = sender.Deliver(context.Background(), validRequest())
var rejected *otpdelivery.RejectedError
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTooManyRequests || rejected.Code != "RATE_LIMITED" || !rejected.Retryable {
t.Fatalf("rejection = %#v err=%v", rejected, err)
}
if errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
t.Fatalf("explicit rejection marked unknown: %v", err)
}
}
func TestDeliverMalformedSuccessIsOutcomeUnknown(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{}`)
}))
defer server.Close()
sender, err := New(Config{URL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = sender.Deliver(context.Background(), validRequest())
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) {
t.Fatalf("err = %v, want unknown outcome", err)
}
}
func TestDeliverTransportFailureIsOutcomeUnknown(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection reset after write")
})}
sender, err := New(Config{URL: "https://otp.example.test/send", Client: client})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = sender.Deliver(context.Background(), validRequest())
if !errors.Is(err, otpdelivery.ErrOutcomeUnknown) || !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("err = %v, want unknown transport outcome", err)
}
}
func TestDeliverDoesNotFollowRedirect(t *testing.T) {
targetCalled := false
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
targetCalled = true
}))
defer target.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target.URL)
w.WriteHeader(http.StatusTemporaryRedirect)
}))
defer server.Close()
sender, err := New(Config{URL: server.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = sender.Deliver(context.Background(), validRequest())
var rejected *otpdelivery.RejectedError
if !errors.As(err, &rejected) || rejected.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("err = %v, want redirect rejection", err)
}
if targetCalled {
t.Fatal("redirect target received OTP")
}
}
func validRequest() otpdelivery.Request {
return otpdelivery.Request{
DeliveryID: "otp_valid",
Purpose: otpdelivery.PurposeLoginSMS,
Channel: otpdelivery.ChannelSMS,
Recipient: "15550001001",
Code: "12345",
ExpiresAt: time.Now().Add(time.Minute),
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

View file

@ -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"`

View file

@ -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 {

View file

@ -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,
)

View file

@ -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