236 lines
9.4 KiB
Go
236 lines
9.4 KiB
Go
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)
|
|
})
|
|
}
|