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:latest", 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) }) }