Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
package twofactor_test
import (
"context"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type capturedDriver struct {
deliveries []model.Delivery
fail bool
}
func (*capturedDriver) Channel() model.Channel { return model.Email }
func (*capturedDriver) Normalize(s string) (string, error) { return customer.NormalizeEmail(s) }
func (*capturedDriver) Mask(string) string { return "masked" }
func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error {
d.deliveries = append(d.deliveries, m)
if d.fail {
return merrors.ServiceUnavailable("queue unavailable")
}
return nil
}
func TestServiceTransactionsAndFactors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "twofactor"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
require.NoError(t, err)
records, err := factorstore.Open(zap.NewNop(), factory)
require.NoError(t, err)
driver := &capturedDriver{}
service, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), []byte(strings.Repeat("s", 32)), zap.NewNop(), driver)
require.NoError(t, err)
account := func() *customer.Account {
a := &customer.Account{Status: customer.AccountActive}
a.ID = bson.NewObjectID()
a.Login = a.ID.Hex() + "@example.com"
a.Locale = "ru"
require.NoError(t, stores.Accounts.Create(ctx, a))
return a
}
request := func(a *customer.Account) model.Request {
return model.Request{Subject: a.ID, Scope: model.Scope{Purpose: vmodel.PurposeLogin, ContextRef: bson.NewObjectID().Hex()}, IdempotencyKey: "begin", IP: a.ID.Hex()}
}
begin := func(r model.Request) (model.Challenge, string) {
c, err := service.Begin(ctx, r)
require.NoError(t, err)
return c, driver.deliveries[len(driver.deliveries)-1].Code
}
action := func(context.Context) error { return nil }
t.Run("delivery retry and immutable deadline", func(t *testing.T) {
a := account()
r := request(a)
r.Deadline = time.Now().Add(3 * time.Minute)
driver.fail = true
_, err := service.Begin(ctx, r)
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
first := driver.deliveries[len(driver.deliveries)-1]
driver.fail = false
c, code := begin(r)
require.Equal(t, first.Code, code)
require.Equal(t, first.Key, driver.deliveries[len(driver.deliveries)-1].Key)
require.WithinDuration(t, first.ExpiresAt, c.ExpiresAt, time.Millisecond)
require.False(t, c.ExpiresAt.After(r.Deadline))
require.Equal(t, a.Login, first.Factor.Address)
require.Equal(t, "ru", first.Locale)
require.Equal(t, "masked", c.Target)
r.IdempotencyKey = "resend"
_, err = service.Resend(ctx, r, c.ID)
_, throttled := verification.Throttle(err)
require.True(t, throttled)
_, err = service.ResendSettings(ctx, a.ID, c.ID, "settings", r.IP)
require.ErrorIs(t, err, verification.ErrTokenNotFound)
})
t.Run("scope and one use proof", func(t *testing.T) {
a := account()
r := request(a)
c, code := begin(r)
_, err := service.Verify(ctx, account().ID, r.Scope, c.ID, code)
require.ErrorIs(t, err, verification.ErrTokenNotFound)
other := r.Scope
other.ContextRef = "another-operation"
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
require.ErrorIs(t, err, verification.ErrTokenNotFound)
other = r.Scope
other.Purpose = vmodel.PurposePayout
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
require.ErrorIs(t, err, verification.ErrTokenNotFound)
proof, err := service.Verify(ctx, a.ID, r.Scope, c.ID, code)
require.NoError(t, err)
require.Len(t, proof.Token, 43)
require.Error(t, service.Consume(ctx, a.ID, other, proof, action))
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, func(context.Context) error { return merrors.ServiceUnavailable("operation failed") }), merrors.ErrServiceUnavailable)
require.NoError(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
require.Error(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
require.ErrorIs(t, err, verification.ErrTokenAlreadyUsed)
})
t.Run("atomic operation rollback and concurrent completion", func(t *testing.T) {
a := account()
r := request(a)
c, code := begin(r)
err := service.Complete(ctx, a.ID, r.Scope, c.ID, code, func(tx context.Context) error {
updated := &customer.Account{}
if err := stores.Accounts.Get(tx, a.ID, updated); err != nil {
return err
}
updated.Name = "should rollback"
if err := stores.Accounts.Update(tx, updated); err != nil {
return err
}
return merrors.ServiceUnavailable("session write failed")
})
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
saved := &customer.Account{}
require.NoError(t, stores.Accounts.Get(ctx, a.ID, saved))
require.Empty(t, saved.Name)
id, err := bson.ObjectIDFromHex(c.ID)
require.NoError(t, err)
record, err := records.Challenge(ctx, id)
require.NoError(t, err)
require.Nil(t, record.VerifiedAt)
var successes atomic.Int32
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
if service.Complete(ctx, a.ID, r.Scope, c.ID, code, action) == nil {
successes.Add(1)
}
}()
}
wg.Wait()
require.EqualValues(t, 1, successes.Load())
})
t.Run("bad attempts are committed even with an outer operation", func(t *testing.T) {
a := account()
r := request(a)
c, code := begin(r)
wrong := "000000"
if code == wrong {
wrong = "111111"
}
for range 5 {
require.Error(t, service.Complete(ctx, a.ID, r.Scope, c.ID, wrong, action))
}
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), verification.ErrTokenAttemptsExceeded)
})
t.Run("expired and inactive challenges fail closed", func(t *testing.T) {
a := account()
r := request(a)
c, code := begin(r)
id, err := bson.ObjectIDFromHex(c.ID)
require.NoError(t, err)
record, err := records.Challenge(ctx, id)
require.NoError(t, err)
record.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, records.UpdateChallenge(ctx, record))
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
require.ErrorIs(t, err, verification.ErrTokenExpired)
a = account()
r = request(a)
c, code = begin(r)
a.Status = customer.AccountBlocked
require.NoError(t, stores.Accounts.Update(ctx, a))
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), merrors.ErrUnauthorized)
})
t.Run("verified preference controls delivery and invalidates old proofs", func(t *testing.T) {
a := account()
r := request(a)
old, oldCode := begin(r)
proof, err := service.Verify(ctx, a.ID, r.Scope, old.ID, oldCode)
require.NoError(t, err)
_, err = service.Enroll(ctx, a.ID, "sms", "+10000000000", "sms", r.IP)
require.ErrorIs(t, err, merrors.ErrNotImplemented)
enroll, err := service.Enroll(ctx, a.ID, model.Email, "SECOND@example.com", "enroll", r.IP)
require.NoError(t, err)
code := driver.deliveries[len(driver.deliveries)-1].Code
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
settings, err := service.Settings(ctx, a.ID)
require.NoError(t, err)
require.Len(t, settings.Factors, 1)
settings, err = service.ConfirmEnrollment(ctx, a.ID, enroll.ID, code)
require.NoError(t, err)
require.Len(t, settings.Factors, 2)
require.Equal(t, "primary-email", settings.Preferred)
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, action), merrors.ErrUnauthorized)
selected := settings.Factors[1].ID
change, err := service.BeginPreference(ctx, a.ID, selected, "preference", r.IP)
require.NoError(t, err)
d := driver.deliveries[len(driver.deliveries)-1]
require.Equal(t, a.Login, d.Factor.Address)
_, err = service.ConfirmPreference(ctx, a.ID, "primary-email", change.ID, d.Code)
require.ErrorIs(t, err, verification.ErrTokenNotFound)
settings, err = service.ConfirmPreference(ctx, a.ID, selected, change.ID, d.Code)
require.NoError(t, err)
require.Equal(t, selected, settings.Preferred)
next := request(a)
_, _ = begin(next)
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
})
}