Organize backend into product executable and shared Go modules

This commit is contained in:
Stephan D committed 2026-09-12 14:14:08 +02:00
1 parent 981e84a4b4
commit f0720ec66b
382 files changed
+1944 -6989

No files matched your search

@@ -0,0 +1,80 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *verificationDB) Consume(
ct context.Context,
accountRef bson.ObjectID,
purpose verificationmodel.VerificationPurpose,
contextRef string,
rawToken string,
) (*verificationmodel.VerificationToken, error) {
scope, err := newConsumeScope(accountRef, purpose, contextRef, rawToken)
if err != nil {
return nil, err
}
db.Logger.Debug("Verification consume started", scope.startFields()...)
result, execErr := db.tf.CreateTransaction().Execute(
ct,
func(ctx context.Context) (any, error) {
token, err := db.lookupConsumeToken(ctx, scope)
if err != nil {
return nil, err
}
if err := db.validateConsumeToken(scope, token); err != nil {
return nil, err
}
return db.consumeMatchedToken(ctx, scope, token)
},
)
if execErr != nil {
db.persistConsumeMissAttempts(ct, scope, execErr)
}
return db.finishConsume(scope, result, execErr)
}
func (db *verificationDB) persistConsumeMissAttempts(ctx context.Context, scope consumeScope, err error) {
var miss consumeMissError
if !errors.As(err, &miss) {
return
}
db.incrementConsumeAttemptsForTokenIDs(ctx, scope, miss.tokenIDs)
}
func (db *verificationDB) finishConsume(
scope consumeScope,
result any,
err error,
) (*verificationmodel.VerificationToken, error) {
if err != nil {
db.Logger.Debug("Verification consume finished with error",
scope.logFields(zap.Error(err))...,
)
return nil, err
}
token, ok := result.(*verificationmodel.VerificationToken)
if !ok {
db.Logger.Warn("Verification consume returned unexpected result type",
scope.logFields(zap.String("result_type", "<non-verification-token>"))...,
)
return nil, merrors.Internal("unexpected token type")
}
db.Logger.Debug("Verification consume finished successfully",
scope.tokenLogFields(token)...,
)
return token, nil
}
@@ -0,0 +1,66 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"go.uber.org/zap"
"strings"
)
const hashPreviewChars = 12
func hashPreview(value string) string {
if value == "" {
return ""
}
if len(value) <= hashPreviewChars {
return value
}
return value[:hashPreviewChars] + "..."
}
func tokenIsDigitsOnly(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
fields := []zap.Field{
mzap.StorableRef(token),
mzap.ObjRef("token_account_ref", token.AccountRef),
zap.String("token_purpose", string(token.Purpose)),
zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""),
zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil),
zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)),
zap.Bool("token_has_salt", token.Salt != nil),
zap.Bool("token_used", token.UsedAt != nil),
zap.Time("token_expires_at", token.ExpiresAt),
zap.Int("token_attempts", token.Attempts),
zap.Bool("token_has_max_retries", token.MaxRetries != nil),
}
if token.Salt != nil {
fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt)))
}
if token.UsedAt != nil {
fields = append(fields, zap.Time("token_used_at", *token.UsedAt))
}
if token.MaxRetries != nil {
fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries))
}
if token.IdempotencyKey != nil {
fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey)))
}
if strings.TrimSpace(token.ContextRef) != "" {
fields = append(fields, zap.String("token_context_ref", token.ContextRef))
}
return fields
}
@@ -0,0 +1,183 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type consumeMissError struct {
cause error
tokenIDs []bson.ObjectID
}
func newConsumeMissError(tokens []verificationmodel.VerificationToken) consumeMissError {
tokenIDs := make([]bson.ObjectID, 0, len(tokens))
for i := range tokens {
if tokens[i].ID != bson.NilObjectID {
tokenIDs = append(tokenIDs, tokens[i].ID)
}
}
return consumeMissError{
cause: ErorrTokenNotFound(),
tokenIDs: tokenIDs,
}
}
func (e consumeMissError) Error() string {
return e.cause.Error()
}
func (e consumeMissError) Unwrap() error {
return e.cause
}
func (db *verificationDB) lookupConsumeToken(
ctx context.Context,
scope consumeScope,
) (*verificationmodel.VerificationToken, error) {
scopeFilter := scope.scopeFilter()
db.Logger.Debug("Verification consume scope filter prepared", scope.logFields()...)
token, err := db.lookupConsumeMagicToken(ctx, scope)
if err != nil {
return nil, err
}
if token != nil {
return token, nil
}
if !scope.accountScoped {
db.Logger.Debug("Verification consume rejected unscoped OTP scan", scope.logFields()...)
return nil, ErorrTokenNotFound()
}
return db.lookupConsumeOTPToken(ctx, scope, scopeFilter)
}
func (db *verificationDB) lookupConsumeMagicToken(
ctx context.Context,
scope consumeScope,
) (*verificationmodel.VerificationToken, error) {
magicFilter := repository.Query().And(
append(
scope.scopeFilters(),
repository.Filter("verifyTokenHash", tokenHash(scope.rawToken)),
)...,
)
db.Logger.Debug("Verification consume magic path lookup", scope.logFields()...)
var direct verificationmodel.VerificationToken
err := db.FindOne(ctx, magicFilter, &direct)
switch {
case err == nil:
db.Logger.Debug("Verification consume matched by magic hash path",
scope.tokenLogFields(&direct)...,
)
return &direct, nil
case errors.Is(err, merrors.ErrNoData):
db.Logger.Debug("Verification consume no direct magic match", scope.logFields()...)
return nil, nil
default:
db.Logger.Warn("Verification consume magic path lookup failed",
scope.logFields(zap.Error(err))...,
)
return nil, err
}
}
func (db *verificationDB) lookupConsumeOTPToken(
ctx context.Context,
scope consumeScope,
scopeFilter builder.Query,
) (*verificationmodel.VerificationToken, error) {
db.Logger.Debug("Verification consume OTP fallback lookup started",
scope.logFields(zap.Any("scope_filter", scopeFilter.BuildQuery()))...,
)
tokens, err := mutil.GetObjects[verificationmodel.VerificationToken](
ctx, db.Logger, scopeFilter, nil, db.Repository,
)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
db.Logger.Debug("Verification consume OTP fallback found no scoped tokens", scope.logFields()...)
return nil, ErorrTokenNotFound()
}
db.Logger.Warn("Verification consume OTP fallback query failed",
scope.logFields(zap.Error(err))...,
)
return nil, err
}
db.Logger.Debug("Verification consume OTP fallback loaded candidates",
scope.logFields(zap.Int("candidate_count", len(tokens)))...,
)
for i := range tokens {
token := &tokens[i]
hash := hasherFor(token).Hash(scope.rawToken, token)
match := hash == token.VerifyTokenHash
db.Logger.Debug("Verification consume OTP candidate evaluated",
zap.Int("candidate_index", i),
zap.Int("candidate_total", len(tokens)),
mzap.ObjRef("candidate_ref", token.ID),
zap.Bool("candidate_has_salt", token.Salt != nil),
zap.Bool("candidate_used", token.UsedAt != nil),
zap.Time("candidate_expires_at", token.ExpiresAt),
zap.Int("candidate_attempts", token.Attempts),
zap.Bool("candidate_has_max_retries", token.MaxRetries != nil),
zap.String("candidate_stored_hash_prefix", hashPreview(token.VerifyTokenHash)),
zap.String("candidate_computed_hash_prefix", hashPreview(hash)),
zap.Bool("candidate_match", match),
)
if match {
return token, nil
}
}
return nil, newConsumeMissError(tokens)
}
func (db *verificationDB) incrementConsumeAttemptsForTokenIDs(
ctx context.Context,
scope consumeScope,
tokenIDs []bson.ObjectID,
) {
if len(tokenIDs) == 0 {
return
}
db.Logger.Debug("Verification consume no OTP candidate matched, incrementing attempts for active scoped tokens",
scope.logFields(zap.Int("candidate_count", len(tokenIDs)))...,
)
filters := append(
scope.scopeFilters(),
repository.Query().Comparison(repository.IDField(), builder.In, tokenIDs),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, scope.now),
)
incremented, err := db.PatchMany(
ctx,
repository.Query().And(filters...),
repository.Patch().Inc(repository.Field("attempts"), 1),
)
if err != nil {
db.Logger.Warn("Verification consume failed to increment attempts for unmatched token",
scope.logFields(zap.Error(err))...,
)
return
}
db.Logger.Debug("Verification consume attempts increment for unmatched token executed",
scope.logFields(
zap.Int("updated_count", incremented),
zap.String("transaction_note", "this update occurs after failed consume transaction so not_found does not roll it back"),
)...,
)
}
@@ -0,0 +1,112 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type consumeScope struct {
now time.Time
accountRef bson.ObjectID
purpose verificationmodel.VerificationPurpose
contextRef string
rawToken string
trimmedRawToken string
accountScoped bool
accountRefHex string
}
func newConsumeScope(
accountRef bson.ObjectID,
purpose verificationmodel.VerificationPurpose,
contextRef string,
rawToken string,
) (consumeScope, error) {
normalizedContextRef, err := normalizeContextRef(purpose, contextRef)
if err != nil {
return consumeScope{}, err
}
accountScoped := accountRef != bson.NilObjectID
accountRefHex := "<nil>"
if accountScoped {
accountRefHex = accountRef.Hex()
}
return consumeScope{
now: time.Now().UTC(),
accountRef: accountRef,
purpose: purpose,
contextRef: normalizedContextRef,
rawToken: rawToken,
trimmedRawToken: strings.TrimSpace(rawToken),
accountScoped: accountScoped,
accountRefHex: accountRefHex,
}, nil
}
func (scope consumeScope) logFields(extra ...zap.Field) []zap.Field {
fields := []zap.Field{
zap.String("purpose", string(scope.purpose)),
zap.Bool("account_scoped", scope.accountScoped),
zap.String("account_ref", scope.accountRefHex),
}
return append(fields, extra...)
}
func (scope consumeScope) tokenLogFields(token *verificationmodel.VerificationToken, extra ...zap.Field) []zap.Field {
fields := scope.logFields(extra...)
return append(fields, tokenStateFields(token)...)
}
func (scope consumeScope) startFields() []zap.Field {
return scope.logFields(
zap.Time("now_utc", scope.now),
zap.Int("raw_token_len", len(scope.rawToken)),
zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)),
zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)),
zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken),
zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))),
)
}
func (scope consumeScope) scopeFilters() []builder.Query {
filters := []builder.Query{
repository.Filter("purpose", scope.purpose),
}
if scope.accountScoped {
filters = append(filters, repository.Filter("accountRef", scope.accountRef))
}
filters = appendContextRefFilter(filters, scope.purpose, scope.contextRef)
return filters
}
func (scope consumeScope) scopeFilter() builder.Query {
return repository.Query().And(scope.scopeFilters()...)
}
func (scope consumeScope) consumeFilter(token *verificationmodel.VerificationToken) builder.Query {
filters := []builder.Query{
repository.IDFilter(token.ID),
repository.Filter("purpose", scope.purpose),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, scope.now),
}
filters = appendContextRefFilter(filters, scope.purpose, scope.contextRef)
if scope.accountScoped {
filters = append(filters, repository.Filter("accountRef", scope.accountRef))
}
if token.MaxRetries != nil {
filters = append(
filters,
repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries),
)
}
return repository.Query().And(filters...)
}
@@ -0,0 +1,149 @@
package verification
import (
"context"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"go.uber.org/zap"
)
func (db *verificationDB) validateConsumeToken(
scope consumeScope,
token *verificationmodel.VerificationToken,
) error {
if token.UsedAt != nil {
db.Logger.Debug("Verification consume static check failed: token already used",
scope.tokenLogFields(token)...,
)
return ErorrTokenAlreadyUsed()
}
if !token.ExpiresAt.After(scope.now) {
db.Logger.Debug("Verification consume static check failed: token expired",
scope.tokenLogFields(token, zap.Time("now_utc", scope.now))...,
)
return ErorrTokenExpired()
}
if token.MaxRetries != nil && token.Attempts >= *token.MaxRetries {
db.Logger.Debug("Verification consume static check failed: attempts exceeded",
scope.tokenLogFields(token)...,
)
return ErrorTokenAttemptsExceeded()
}
db.Logger.Debug("Verification consume static checks passed",
scope.tokenLogFields(token)...,
)
return nil
}
func (db *verificationDB) consumeMatchedToken(
ctx context.Context,
scope consumeScope,
token *verificationmodel.VerificationToken,
) (*verificationmodel.VerificationToken, error) {
db.Logger.Debug("Verification consume atomic update attempt",
scope.logFields(mzap.StorableRef(token))...,
)
updated, err := db.PatchMany(
ctx,
scope.consumeFilter(token),
repository.Patch().Set(repository.Field("usedAt"), scope.now),
)
if err != nil {
db.Logger.Warn("Verification consume atomic update failed",
scope.logFields(mzap.StorableRef(token), zap.Error(err))...,
)
return nil, err
}
db.Logger.Debug("Verification consume atomic update result",
scope.logFields(mzap.StorableRef(token), zap.Int("updated_count", updated))...,
)
if updated == 1 {
token.UsedAt = &scope.now
db.Logger.Debug("Verification consume succeeded", scope.tokenLogFields(token)...)
return token, nil
}
db.incrementConsumeAttemptsAfterAtomicMiss(ctx, scope, token)
return nil, db.consumeMissError(ctx, scope, token)
}
func (db *verificationDB) incrementConsumeAttemptsAfterAtomicMiss(
ctx context.Context,
scope consumeScope,
token *verificationmodel.VerificationToken,
) {
incremented, err := db.PatchMany(
ctx,
repository.IDFilter(token.ID),
repository.Patch().Inc(repository.Field("attempts"), 1),
)
if err != nil {
db.Logger.Warn("Verification consume failed to increment attempts after atomic update miss",
scope.logFields(mzap.StorableRef(token), zap.Error(err))...,
)
return
}
db.Logger.Debug("Verification consume incremented attempts after atomic update miss",
scope.logFields(
mzap.StorableRef(token),
zap.Int("updated_count", incremented),
zap.String("transaction_note", "this update occurs inside transaction and may roll back depending on returned error"),
)...,
)
}
func (db *verificationDB) consumeMissError(
ctx context.Context,
scope consumeScope,
token *verificationmodel.VerificationToken,
) error {
var fresh verificationmodel.VerificationToken
if err := db.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil {
db.Logger.Warn("Verification consume failed to re-check token state",
scope.logFields(mzap.StorableRef(token), zap.Error(err))...,
)
return merrors.Internal("failed to re-check token state")
}
db.Logger.Debug("Verification consume re-checked token state after atomic miss",
scope.tokenLogFields(&fresh, mzap.StorableRef(token))...,
)
if fresh.UsedAt != nil {
db.Logger.Debug("Verification consume final decision: already used after re-check",
scope.logFields(mzap.StorableRef(token))...,
)
return ErorrTokenAlreadyUsed()
}
if !fresh.ExpiresAt.After(scope.now) {
db.Logger.Debug("Verification consume final decision: expired after re-check",
scope.logFields(
mzap.StorableRef(token),
zap.Time("now_utc", scope.now),
zap.Time("token_expires_at", fresh.ExpiresAt),
)...,
)
return ErorrTokenExpired()
}
if fresh.MaxRetries != nil && fresh.Attempts >= *fresh.MaxRetries {
db.Logger.Debug("Verification consume final decision: attempts exceeded after re-check",
scope.logFields(
mzap.StorableRef(token),
zap.Int("token_attempts", fresh.Attempts),
zap.Int("token_max_retries", *fresh.MaxRetries),
)...,
)
return ErrorTokenAttemptsExceeded()
}
db.Logger.Debug("Verification consume final decision: token not found after re-check",
scope.logFields(mzap.StorableRef(token))...,
)
return ErorrTokenNotFound()
}
@@ -0,0 +1,22 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
)
func normalizeContextRef(purpose verificationmodel.VerificationPurpose, contextRef string) (string, error) {
_ = purpose
return strings.TrimSpace(contextRef), nil
}
func appendContextRefFilter(filters []builder.Query, purpose verificationmodel.VerificationPurpose, contextRef string) []builder.Query {
_ = purpose
if strings.TrimSpace(contextRef) != "" {
filters = append(filters, repository.Filter("contextRef", strings.TrimSpace(contextRef)))
}
return filters
}
@@ -0,0 +1,98 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"testing"
"time"
)
func TestCreate_BlankContextRefAllowed(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
_, err := db.Create(ctx, contextReq(accountRef, ""))
require.NoError(t, err)
}
func TestCreate_OldContextDoesNotBlockNewContext(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
oldRaw, err := db.Create(ctx, contextReq(accountRef, "ctx-old"))
require.NoError(t, err)
newRaw, err := db.Create(ctx, contextReq(accountRef, "ctx-new").WithCooldown(time.Minute))
require.NoError(t, err)
assert.NotEqual(t, oldRaw, newRaw)
oldTok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-old", oldRaw)
require.NoError(t, err)
assert.Equal(t, "ctx-old", oldTok.ContextRef)
newTok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-new", newRaw)
require.NoError(t, err)
assert.Equal(t, "ctx-new", newTok.ContextRef)
}
func TestConsume_OTPForOneContextCannotConfirmAnotherContext(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, contextReq(accountRef, "ctx-a"))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-b", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenNotFound))
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx-a", raw)
require.NoError(t, err)
assert.Equal(t, "ctx-a", tok.ContextRef)
}
func TestCreate_ContextIdempotencyReplayWorksOnlyWithinSameContext(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstReq := contextReq(accountRef, "ctx-a").
WithCooldown(time.Minute).
WithIdempotencyKey("same-key")
firstRaw, err := db.Create(ctx, firstReq)
require.NoError(t, err)
require.NotEmpty(t, firstRaw)
replayReq := contextReq(accountRef, "ctx-a").
WithCooldown(time.Minute).
WithIdempotencyKey("same-key")
replayRaw, err := db.Create(ctx, replayReq)
require.NoError(t, err)
assert.Equal(t, firstRaw, replayRaw)
conflictingReq := contextReq(accountRef, "ctx-b").
WithIdempotencyKey("same-key")
_, err = db.Create(ctx, conflictingReq)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrIdempotencyConflict))
}
func TestCreate_ContextRefStoredForAnyPurpose(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithContextRef("ctx"))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "ctx", raw)
require.NoError(t, err)
assert.Equal(t, "ctx", tok.ContextRef)
}
@@ -0,0 +1,248 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
)
type CreateResult struct {
VerificationToken string
Created bool
ExpiresAt time.Time
}
func activeContextFilter(request *Request, now time.Time) builder.Query {
filters := []builder.Query{
repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
}
filters = appendContextRefFilter(filters, request.Purpose, request.ContextRef)
return repository.Query().And(filters...)
}
func cooldownActiveContextFilter(request *Request, now, cutoff time.Time) builder.Query {
filters := []builder.Query{
repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
}
filters = appendContextRefFilter(filters, request.Purpose, request.ContextRef)
return repository.Query().And(filters...)
}
func hashFilter(hash string) builder.Query {
return repository.Filter("verifyTokenHash", hash)
}
func storedVerificationInstant(value time.Time) time.Time {
return value.UTC().Truncate(time.Millisecond)
}
func newVerificationToken(
request *Request,
idempotencyKey string,
hasIdempotency bool,
) (*verificationmodel.VerificationToken, string, error) {
now := storedVerificationInstant(time.Now())
var (
raw string
hash string
salt *string
err error
)
switch request.Kind {
case TokenKindOTP:
if hasIdempotency {
var saltValue string
raw, saltValue, hash = generateDeterministicOTP(idempotencySeed(request, idempotencyKey))
salt = &saltValue
} else {
var s string
raw, s, hash, err = generateOTP()
if err != nil {
return nil, "", err
}
salt = &s
}
default: // Magic token
if hasIdempotency {
raw, hash = generateDeterministicMagic(idempotencySeed(request, idempotencyKey))
} else {
raw, hash, err = generateMagic()
if err != nil {
return nil, "", err
}
}
}
token := &verificationmodel.VerificationToken{
AccountRef: request.AccountRef,
Purpose: request.Purpose,
Target: request.Target,
ContextRef: request.ContextRef,
IdempotencyKey: nil,
VerifyTokenHash: hash,
Salt: salt,
UsedAt: nil,
ExpiresAt: storedVerificationInstant(now.Add(request.Ttl)),
MaxRetries: request.MaxRetries,
}
if hasIdempotency {
token.IdempotencyKey = &idempotencyKey
}
return token, raw, nil
}
func (db *verificationDB) Create(
ctx context.Context,
request *Request,
) (string, error) {
result, err := db.CreateWithResult(ctx, request)
if err != nil {
return "", err
}
return result.VerificationToken, nil
}
func (db *verificationDB) CreateWithResult(
ctx context.Context,
request *Request,
) (CreateResult, error) {
if request == nil {
return CreateResult{}, merrors.Internal("nil request")
}
contextRef, err := normalizeContextRef(request.Purpose, request.ContextRef)
if err != nil {
return CreateResult{}, err
}
request.ContextRef = contextRef
idempotencyKey, hasIdempotency := normalizedIdempotencyKey(request.IdempotencyKey)
if !hasIdempotency {
// Always store an idempotency key. Client keys enable replay; generated
// keys avoid null-key collisions on older Mongo indexes and keep
// transaction retries deterministic.
idempotencyKey = syntheticIdempotencyKey()
hasIdempotency = true
}
token, raw, err := newVerificationToken(request, idempotencyKey, hasIdempotency)
if err != nil {
return CreateResult{}, err
}
result, err := db.tf.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
now := time.Now().UTC()
activeFilter := activeContextFilter(request, now)
// Idempotency and transaction-retry support.
if hasIdempotency {
replayedToken, replayed, err := db.lookupIdempotencyReplay(tx, request, idempotencyKey)
if err != nil {
return nil, err
}
if replayed {
return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil
}
var sameToken verificationmodel.VerificationToken
err = db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken)
switch {
case err == nil:
// Same hash means the same Create operation already succeeded.
return CreateResult{Created: true, ExpiresAt: sameToken.ExpiresAt}, nil
case errors.Is(err, merrors.ErrNoData):
default:
return nil, err
}
}
// 1) Cooldown: if there exists ANY active token created after cutoff → block
if request.Cooldown != nil {
cutoff := now.Add(-*request.Cooldown)
var recent verificationmodel.VerificationToken
err := db.FindOne(tx, cooldownActiveContextFilter(request, now, cutoff), &recent)
switch {
case err == nil:
retryAfter := recent.CreatedAt.Add(*request.Cooldown).Sub(now)
return nil, ErrorCooldownActiveFor(retryAfter)
case errors.Is(err, merrors.ErrNoData):
default:
return nil, err
}
}
// Reserve every abuse-control scope in the same transaction as token
// creation. A denied request rolls back all reservations and cannot race
// another instance into publishing an extra code.
if err := db.reserveIssuance(tx, request, now); err != nil {
return nil, err
}
// 2) Invalidate active tokens for this context
if _, err := db.PatchMany(
tx,
activeFilter,
repository.Patch().Set(repository.Field("usedAt"), now),
); err != nil {
return nil, err
}
// 3) Create new token only after cooldown/idempotency checks pass.
if err := db.DBImp.Create(tx, token); err != nil {
if hasIdempotency && errors.Is(err, merrors.ErrDataConflict) {
replayedToken, replayed, replayErr := db.lookupIdempotencyReplay(tx, request, idempotencyKey)
if replayErr != nil {
return nil, replayErr
}
if replayed {
return CreateResult{Created: false, ExpiresAt: replayedToken.ExpiresAt}, nil
}
var sameToken verificationmodel.VerificationToken
findErr := db.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken)
switch {
case findErr == nil:
return CreateResult{Created: true, ExpiresAt: sameToken.ExpiresAt}, nil
case errors.Is(findErr, merrors.ErrNoData):
default:
return nil, findErr
}
}
return nil, err
}
return CreateResult{Created: true, ExpiresAt: token.ExpiresAt}, nil
})
if err != nil {
return CreateResult{}, err
}
createResult, ok := result.(CreateResult)
if !ok {
return CreateResult{}, merrors.Internal("unexpected verification create result")
}
createResult.VerificationToken = raw
return createResult, nil
}
+88
View File
@@ -0,0 +1,88 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type verificationDB struct {
template.DBImp[*verificationmodel.VerificationToken]
tf transaction.Factory
limits ratelimits.Store
}
func (db *verificationDB) WithIssuanceLimits(limits ratelimits.Store) *verificationDB {
db.limits = limits
return db
}
const verificationContextIdempotencyIndexName = "uniq_verification_context_idempotency_v2"
func Create(
logger mlogger.Logger,
db *mongo.Database,
tf transaction.Factory,
) (*verificationDB, error) {
return CreateWithCollection(logger, db, tf, "")
}
func CreateWithCollection(
logger mlogger.Logger,
db *mongo.Database,
tf transaction.Factory,
collection string,
) (*verificationDB, error) {
if strings.TrimSpace(collection) == "" {
collection = mservice.VerificationTokens
}
p := &verificationDB{
DBImp: *template.Create[*verificationmodel.VerificationToken](logger, mservice.Type(collection), db),
tf: tf,
}
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: "verifyTokenHash", Sort: ri.Asc}},
Unique: true,
Name: "unique_token_hash",
}); err != nil {
p.Logger.Error("Failed to create unique verifyTokenHash index", zap.Error(err))
return nil, err
}
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
{Field: "purpose", Sort: ri.Asc},
{Field: "target", Sort: ri.Asc},
{Field: "contextRef", Sort: ri.Asc},
{Field: "idempotencyKey", Sort: ri.Asc},
},
Unique: true,
Sparse: true,
Name: verificationContextIdempotencyIndexName,
}); err != nil {
p.Logger.Error("Failed to create unique idempotency index on verification context", zap.Error(err))
return nil, err
}
ttl := int32(2678400) // 30 days
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
TTL: &ttl,
Name: "ttl_expires_at",
}); err != nil {
p.Logger.Error("Failed to create TTL index on expiresAt", zap.Error(err))
return nil, err
}
return p, nil
}
@@ -0,0 +1,69 @@
package verification
import (
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
)
var (
ErrTokenNotFound = merrors.Define("vtNotFound")
ErrTokenAlreadyUsed = merrors.Define("vtAlreadyUsed")
ErrTokenExpired = merrors.Define("vtExpired")
ErrTokenAttemptsExceeded = merrors.Define("vtAttemptsExceeded")
ErrCooldownActive = merrors.Define("vtCooldownActive")
ErrIdempotencyConflict = merrors.Define("vtIdempotencyConflict")
ErrIssuanceRateLimited = merrors.Define("vtIssuanceRateLimited")
)
type ThrottleDetails = merrors.ThrottleDetails
func Throttle(err error) (ThrottleDetails, bool) {
return merrors.ThrottleDetailsFrom(err)
}
func wrap(err error, msg string) error {
return merrors.Wrap(err, msg)
}
func ErorrTokenNotFound() error {
return wrap(ErrTokenNotFound, "verification token not found")
}
func ErorrTokenAlreadyUsed() error {
return wrap(ErrTokenAlreadyUsed, "verification token has already been used")
}
func ErorrTokenExpired() error {
return wrap(ErrTokenExpired, "verification token expired")
}
func ErrorCooldownActive() error {
return ErrorCooldownActiveFor(0)
}
func ErrorCooldownActiveFor(retryAfter time.Duration) error {
return merrors.WithThrottle(
ErrCooldownActive,
"cooldown",
retryAfter,
"verification request is temporarily limited",
)
}
func ErrorIssuanceRateLimited(scopeKind string, retryAfter time.Duration) error {
return merrors.WithThrottle(
ErrIssuanceRateLimited,
scopeKind,
retryAfter,
"verification request is temporarily limited",
)
}
func ErrorTokenAttemptsExceeded() error {
return wrap(ErrTokenAttemptsExceeded, "verification token max attempts exceeded")
}
func ErrorIdempotencyConflict() error {
return wrap(ErrIdempotencyConflict, "verification token request idempotency key has already been used")
}
+105
View File
@@ -0,0 +1,105 @@
package verification
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
)
type TokenHasher interface {
Hash(raw string, token *verificationmodel.VerificationToken) string
}
type magicHasher struct{}
func (magicHasher) Hash(raw string, _ *verificationmodel.VerificationToken) string {
return tokenHash(raw)
}
type otpHasher struct{}
func (otpHasher) Hash(raw string, t *verificationmodel.VerificationToken) string {
return otpHash(raw, *t.Salt)
}
func hasherFor(t *verificationmodel.VerificationToken) TokenHasher {
if t.Salt != nil {
return otpHasher{}
}
return magicHasher{}
}
const verificationTokenBytes = 32
const otpDigits = 6
func generateMagic() (raw, hash string, err error) {
rawBytes := make([]byte, verificationTokenBytes)
if _, err = rand.Read(rawBytes); err != nil {
return
}
raw = base64.RawURLEncoding.EncodeToString(rawBytes)
hash = tokenHash(raw)
return
}
func generateDeterministicMagic(seed string) (raw, hash string) {
sum := sha256.Sum256([]byte("magic:" + seed))
raw = base64.RawURLEncoding.EncodeToString(sum[:])
hash = tokenHash(raw)
return
}
func generateOTP() (code, salt, hash string, err error) {
// otpDigits-digit code
n := make([]byte, 4)
if _, err = rand.Read(n); err != nil {
return
}
num := int(n[0])<<24 | int(n[1])<<16 | int(n[2])<<8 | int(n[3])
mod := 1
for i := 0; i < otpDigits; i++ {
mod *= 10
}
code = fmt.Sprintf("%0*d", otpDigits, num%mod)
// per-token salt
saltBytes := make([]byte, 16)
if _, err = rand.Read(saltBytes); err != nil {
return
}
salt = base64.RawURLEncoding.EncodeToString(saltBytes)
hash = otpHash(code, salt)
return
}
func generateDeterministicOTP(seed string) (code, salt, hash string) {
sum := sha256.Sum256([]byte("otp:" + seed))
num := int(sum[0])<<24 | int(sum[1])<<16 | int(sum[2])<<8 | int(sum[3])
mod := 1
for i := 0; i < otpDigits; i++ {
mod *= 10
}
code = fmt.Sprintf("%0*d", otpDigits, num%mod)
salt = base64.RawURLEncoding.EncodeToString(sum[4:20])
hash = otpHash(code, salt)
return
}
// We store only the resulting hash (+salt) in DB, never the OTP itself.
func otpHash(code, salt string) string {
sum := sha256.Sum256([]byte(salt + ":" + code))
return hex.EncodeToString(sum[:])
}
func tokenHash(rawToken string) string {
hash := sha256.Sum256([]byte(rawToken))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
@@ -0,0 +1,19 @@
package verification
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTokenHash_Deterministic(t *testing.T) {
h1 := tokenHash("same-input")
h2 := tokenHash("same-input")
assert.Equal(t, h1, h2)
}
func TestTokenHash_DifferentInputs(t *testing.T) {
h1 := tokenHash("input-a")
h2 := tokenHash("input-b")
assert.NotEqual(t, h1, h2)
}
@@ -0,0 +1,84 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings"
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func normalizedIdempotencyKey(value *string) (string, bool) {
if value == nil {
return "", false
}
key := strings.TrimSpace(*value)
if key == "" {
return "", false
}
return key, true
}
func syntheticIdempotencyKey() string {
return "auto:" + bson.NewObjectID().Hex()
}
func idempotencyContextFilter(
request *Request,
idempotencyKey string,
) builder.Query {
return repository.Query().And(
repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target),
repository.Filter("idempotencyKey", idempotencyKey),
)
}
func idempotencySeed(request *Request, idempotencyKey string) string {
return strings.Join([]string{
request.AccountRef.Hex(),
string(request.Purpose),
request.Target,
request.ContextRef,
request.Kind,
idempotencyKey,
}, "|")
}
func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *Request, idempotencyKey string) (*verificationmodel.VerificationToken, bool, error) {
tokens, err := mutil.GetObjects[verificationmodel.VerificationToken](
ctx,
db.Logger,
idempotencyContextFilter(request, idempotencyKey),
nil,
db.Repository,
)
switch {
case err == nil:
case errors.Is(err, merrors.ErrNoData):
return nil, false, nil
default:
return nil, false, err
}
var replayed *verificationmodel.VerificationToken
for i := range tokens {
existingContextRef := strings.TrimSpace(tokens[i].ContextRef)
// The lookup intentionally ignores contextRef. A reused idempotency key
// in another context is a client conflict, not a replay.
if existingContextRef != request.ContextRef {
return nil, false, ErrorIdempotencyConflict()
}
if replayed == nil {
token := tokens[i]
replayed = &token
}
}
return replayed, replayed != nil, nil
}
@@ -0,0 +1,47 @@
package verification
import (
"context"
"strings"
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func (db *verificationDB) reserveIssuance(ctx context.Context, request *Request, now time.Time) error {
if len(request.IssuanceLimits) == 0 {
return nil
}
if db.limits == nil {
return merrors.ServiceUnavailable("verification rate-limit storage is unavailable")
}
for _, limit := range request.IssuanceLimits {
scopeKind := strings.TrimSpace(limit.ScopeKind)
if scopeKind == "" || strings.TrimSpace(limit.ScopeHash) == "" || limit.Limit < 1 ||
limit.WindowStart.IsZero() || !limit.ExpiresAt.After(limit.WindowStart) {
return merrors.InvalidArgument("invalid verification issuance limit", "issuanceLimits")
}
count, err := db.limits.Increment(
ctx,
scopeKind,
limit.ScopeHash,
limit.WindowStart,
limit.ExpiresAt,
)
if err != nil {
return merrors.OpaqueWrap(
merrors.ErrServiceUnavailable,
err,
"failed to reserve verification issuance capacity",
)
}
if count > limit.Limit {
retryAfter := limit.ExpiresAt.Sub(now)
if retryAfter < time.Second {
retryAfter = time.Second
}
return ErrorIssuanceRateLimited(scopeKind, retryAfter)
}
}
return nil
}
@@ -0,0 +1,171 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"sync"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestReserveIssuanceEnforcesThreshold(t *testing.T) {
now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
store := newMemoryIssuanceStore()
db := &verificationDB{limits: store}
request := &Request{IssuanceLimits: []IssuanceLimit{
NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now),
}}
for attempt := 1; attempt <= 2; attempt++ {
if err := db.reserveIssuance(context.Background(), request, now); err != nil {
t.Fatalf("attempt %d was unexpectedly rejected: %v", attempt, err)
}
}
err := db.reserveIssuance(context.Background(), request, now)
if !errors.Is(err, ErrIssuanceRateLimited) {
t.Fatalf("expected rate-limit error, got %v", err)
}
details, ok := Throttle(err)
if !ok || details.ScopeKind != "destination" || details.RetryAfter != time.Hour {
t.Fatalf("unexpected throttle details: %+v, ok=%v", details, ok)
}
nextWindow := &Request{IssuanceLimits: []IssuanceLimit{
NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now.Add(time.Hour)),
}}
if err := db.reserveIssuance(context.Background(), nextWindow, now.Add(time.Hour)); err != nil {
t.Fatalf("first request in the next window was rejected: %v", err)
}
}
func TestCreateWithResultDoesNotMutateTokensWhenIssuanceDenied(t *testing.T) {
now := time.Now().UTC()
store := newMemoryIssuanceStore()
db := newTestVerificationDB(t).WithIssuanceLimits(store)
accountRef := bson.NewObjectID()
limit := NewIssuanceLimit("destination", "login\x00user@example.com", 1, time.Hour, now)
first, err := db.CreateWithResult(context.Background(), NewOTPRequest(
accountRef,
verificationmodel.PurposeLogin,
"user@example.com",
).WithIdempotencyKey("issuance-first").WithIssuanceLimits(limit))
if err != nil || !first.Created {
t.Fatalf("first token creation failed: result=%+v err=%v", first, err)
}
_, err = db.CreateWithResult(context.Background(), NewOTPRequest(
accountRef,
verificationmodel.PurposeLogin,
"user@example.com",
).WithIdempotencyKey("issuance-second").WithIssuanceLimits(limit))
if !errors.Is(err, ErrIssuanceRateLimited) {
t.Fatalf("expected second token creation to be rate-limited, got %v", err)
}
repo, ok := db.Repository.(*memoryTokenRepository)
if !ok {
t.Fatalf("unexpected verification repository type %T", db.Repository)
}
snapshot := repo.snapshot()
if len(snapshot.data) != 1 {
t.Fatalf("rate-limited request changed token count: %d", len(snapshot.data))
}
for _, token := range snapshot.data {
if token.UsedAt != nil {
t.Fatal("rate-limited request invalidated the existing token")
}
}
}
func TestReserveIssuanceIsSharedAcrossInstances(t *testing.T) {
now := time.Date(2026, 8, 13, 12, 15, 0, 0, time.UTC)
store := newMemoryIssuanceStore()
dbs := []*verificationDB{{limits: store}, {limits: store}}
request := &Request{IssuanceLimits: []IssuanceLimit{
NewIssuanceLimit("ip", "login\x00192.0.2.10", 8, time.Hour, now),
}}
var allowed atomic.Int32
var denied atomic.Int32
var wg sync.WaitGroup
for attempt := 0; attempt < 40; attempt++ {
wg.Add(1)
go func(db *verificationDB) {
defer wg.Done()
err := db.reserveIssuance(context.Background(), request, now)
switch {
case err == nil:
allowed.Add(1)
case errors.Is(err, ErrIssuanceRateLimited):
denied.Add(1)
default:
t.Errorf("unexpected issuance error: %v", err)
}
}(dbs[attempt%len(dbs)])
}
wg.Wait()
if got := allowed.Load(); got != 8 {
t.Fatalf("expected exactly 8 allowed reservations, got %d", got)
}
if got := denied.Load(); got != 32 {
t.Fatalf("expected 32 denied reservations, got %d", got)
}
}
func TestReserveIssuanceFailsClosedWithoutLeakingStorageError(t *testing.T) {
now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
cause := merrors.Define("restricted verification destination")
db := &verificationDB{limits: &memoryIssuanceStore{err: cause}}
request := &Request{IssuanceLimits: []IssuanceLimit{
NewIssuanceLimit("destination", "login\x00secret@example.com", 2, time.Hour, now),
}}
err := db.reserveIssuance(context.Background(), request, now)
if !errors.Is(err, merrors.ErrServiceUnavailable) || !errors.Is(err, cause) {
t.Fatalf("expected opaque service-unavailable error preserving cause, got %v", err)
}
if got := err.Error(); got != "serviceUnavailable: failed to reserve verification issuance capacity" {
t.Fatalf("storage error leaked or changed: %q", got)
}
}
type memoryIssuanceStore struct {
mu sync.Mutex
counts map[string]int
err error
}
func newMemoryIssuanceStore() *memoryIssuanceStore {
return &memoryIssuanceStore{counts: make(map[string]int)}
}
func (s *memoryIssuanceStore) Current(_ context.Context, scopeKind, scopeHash string, windowStart time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return 0, s.err
}
return s.counts[issuanceStoreKey(scopeKind, scopeHash, windowStart)], nil
}
func (s *memoryIssuanceStore) Increment(_ context.Context, scopeKind, scopeHash string, windowStart, _ time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return 0, s.err
}
key := issuanceStoreKey(scopeKind, scopeHash, windowStart)
s.counts[key]++
return s.counts[key], nil
}
func issuanceStoreKey(scopeKind, scopeHash string, windowStart time.Time) string {
return scopeKind + "\x00" + scopeHash + "\x00" + windowStart.UTC().Format(time.RFC3339Nano)
}
@@ -0,0 +1,316 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"sync"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type memoryTokenRepository struct {
mu sync.Mutex
data map[bson.ObjectID]*verificationmodel.VerificationToken
order []bson.ObjectID
seq int
}
type memoryTokenRepositorySnapshot struct {
data map[bson.ObjectID]*verificationmodel.VerificationToken
order []bson.ObjectID
seq int
}
func newMemoryTokenRepository() *memoryTokenRepository {
return &memoryTokenRepository{data: make(map[bson.ObjectID]*verificationmodel.VerificationToken)}
}
func (m *memoryTokenRepository) snapshot() memoryTokenRepositorySnapshot {
m.mu.Lock()
defer m.mu.Unlock()
data := make(map[bson.ObjectID]*verificationmodel.VerificationToken, len(m.data))
for id, tok := range m.data {
data[id] = cloneToken(tok)
}
return memoryTokenRepositorySnapshot{
data: data,
order: append([]bson.ObjectID(nil), m.order...),
seq: m.seq,
}
}
func (m *memoryTokenRepository) restore(snapshot memoryTokenRepositorySnapshot) {
m.mu.Lock()
defer m.mu.Unlock()
m.data = make(map[bson.ObjectID]*verificationmodel.VerificationToken, len(snapshot.data))
for id, tok := range snapshot.data {
m.data[id] = cloneToken(tok)
}
m.order = append([]bson.ObjectID(nil), snapshot.order...)
m.seq = snapshot.seq
}
func (m *memoryTokenRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
m.mu.Lock()
defer m.mu.Unlock()
tok, ok := obj.(*verificationmodel.VerificationToken)
if !ok {
return merrors.InvalidDataType("expected VerificationToken")
}
id := tok.GetID()
if id == nil || *id == bson.NilObjectID {
m.seq++
tok.SetID(bson.NewObjectID())
id = tok.GetID()
}
if _, exists := m.data[*id]; exists {
return merrors.DataConflict("token already exists")
}
for _, existing := range m.data {
if existing.VerifyTokenHash == tok.VerifyTokenHash {
return merrors.DataConflict("duplicate verifyTokenHash")
}
if existing.AccountRef != tok.AccountRef {
continue
}
if existing.Purpose != tok.Purpose {
continue
}
if existing.Target != tok.Target {
continue
}
if existing.ContextRef != tok.ContextRef {
continue
}
switch {
case existing.IdempotencyKey == nil && tok.IdempotencyKey == nil:
return merrors.DataConflict("duplicate verification context idempotency")
case existing.IdempotencyKey != nil && tok.IdempotencyKey != nil && *existing.IdempotencyKey == *tok.IdempotencyKey:
return merrors.DataConflict("duplicate verification context idempotency")
}
}
m.data[*id] = cloneToken(tok)
m.order = append(m.order, *id)
return nil
}
func (m *memoryTokenRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
m.mu.Lock()
defer m.mu.Unlock()
tok, ok := m.data[id]
if !ok {
return merrors.ErrNoData
}
dst := result.(*verificationmodel.VerificationToken)
*dst = *cloneToken(tok)
return nil
}
func (m *memoryTokenRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, id := range m.order {
tok := m.data[id]
if tok != nil && matchToken(query, tok) {
dst := result.(*verificationmodel.VerificationToken)
*dst = *cloneToken(tok)
return nil
}
}
return merrors.ErrNoData
}
func (m *memoryTokenRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
return m.FindOneByFilter(ctx, query, result)
}
func (m *memoryTokenRepository) Update(_ context.Context, obj storable.Storable) error {
m.mu.Lock()
defer m.mu.Unlock()
tok := obj.(*verificationmodel.VerificationToken)
id := tok.GetID()
if id == nil {
return merrors.InvalidArgument("id required")
}
if _, exists := m.data[*id]; !exists {
return merrors.ErrNoData
}
m.data[*id] = cloneToken(tok)
return nil
}
func (m *memoryTokenRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
tok, ok := obj.(*verificationmodel.VerificationToken)
if !ok {
return false, merrors.InvalidDataType("expected VerificationToken")
}
for _, id := range m.order {
existing := m.data[id]
if existing == nil || !matchToken(query, existing) {
continue
}
if tokID := tok.GetID(); tokID == nil || *tokID != id {
return false, merrors.InvalidArgument("replace object id mismatch")
}
m.data[id] = cloneToken(tok)
return true, nil
}
return false, nil
}
func (m *memoryTokenRepository) Upsert(ctx context.Context, obj storable.Storable) error {
id := obj.GetID()
if id == nil || *id == bson.NilObjectID {
return m.Insert(ctx, obj, nil)
}
if err := m.Update(ctx, obj); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return m.Insert(ctx, obj, nil)
}
return err
}
return nil
}
func (m *memoryTokenRepository) PatchMany(_ context.Context, filter builder.Query, patch builder.Patch) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
patchDoc := patch.Build()
count := 0
for _, id := range m.order {
tok := m.data[id]
if tok != nil && matchToken(filter, tok) {
applyPatch(tok, patchDoc)
count++
}
}
return count, nil
}
func (m *memoryTokenRepository) PatchOneByFilter(_ context.Context, filter builder.Query, patch builder.Patch, result storable.Storable) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
patchDoc := patch.Build()
for _, id := range m.order {
tok := m.data[id]
if tok == nil || !matchToken(filter, tok) {
continue
}
applyPatch(tok, patchDoc)
if result != nil {
dst := result.(*verificationmodel.VerificationToken)
*dst = *cloneToken(tok)
}
return true, nil
}
return false, nil
}
func (m *memoryTokenRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var count int64
for _, id := range m.order {
tok := m.data[id]
if tok != nil && matchToken(query, tok) {
count++
}
}
return count, nil
}
func (m *memoryTokenRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
return merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) InsertMany(ctx context.Context, objs []storable.Storable) error {
for _, o := range objs {
if err := m.Insert(ctx, o, nil); err != nil {
return err
}
}
return nil
}
func (m *memoryTokenRepository) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
m.mu.Lock()
var matches []interface{}
for _, id := range m.order {
tok := m.data[id]
if tok != nil && matchToken(query, tok) {
raw, err := bson.Marshal(cloneToken(tok))
if err != nil {
m.mu.Unlock()
return err
}
matches = append(matches, bson.Raw(raw))
}
}
m.mu.Unlock()
cur, err := mongo.NewCursorFromDocuments(matches, nil, nil)
if err != nil {
return err
}
defer func() {
_ = cur.Close(ctx)
}()
for cur.Next(ctx) {
if err := decoder(cur); err != nil {
return err
}
}
return nil
}
func (m *memoryTokenRepository) FindAllByFilter(context.Context, builder.Query, any) error {
return merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error {
return merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) Delete(_ context.Context, id bson.ObjectID) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, id)
return nil
}
func (m *memoryTokenRepository) DeleteMany(context.Context, builder.Query) error {
return merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) CreateIndex(*ri.Definition) error { return nil }
func (m *memoryTokenRepository) DropIndex(context.Context, string) error { return nil }
func (m *memoryTokenRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson.ObjectID, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}
func (m *memoryTokenRepository) Collection() string { return mservice.VerificationTokens }
@@ -0,0 +1,102 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings"
"time"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"go.mongodb.org/mongo-driver/v2/bson"
)
type IssuanceLimit struct {
ScopeKind string
ScopeHash string
Limit int
WindowStart time.Time
ExpiresAt time.Time
}
func NewIssuanceLimit(scopeKind, scopeValue string, limit int, window time.Duration, now time.Time) IssuanceLimit {
windowStart := now.UTC().Truncate(window)
return IssuanceLimit{
ScopeKind: strings.TrimSpace(scopeKind),
ScopeHash: ratelimits.HashScope(scopeKind, scopeValue),
Limit: limit,
WindowStart: windowStart,
ExpiresAt: windowStart.Add(window),
}
}
type TokenKind = string
const (
TokenKindOTP TokenKind = "otp"
TokenKindLink TokenKind = "link"
)
type Request struct {
AccountRef bson.ObjectID
Purpose verificationmodel.VerificationPurpose
Target string
ContextRef string
Ttl time.Duration
Kind TokenKind
MaxRetries *int
Cooldown *time.Duration
IdempotencyKey *string // Optional key to make Create idempotent for retries.
IssuanceLimits []IssuanceLimit
}
func (r *Request) WithIssuanceLimits(limits ...IssuanceLimit) *Request {
r.IssuanceLimits = append([]IssuanceLimit(nil), limits...)
return r
}
func newRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string, kind TokenKind) *Request {
return &Request{
AccountRef: accountRef,
Purpose: purpose,
Target: target,
Kind: kind,
Ttl: 15 * time.Minute, // default TTL for verification tokens
}
}
func NewLinkRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string) *Request {
return newRequest(accountRef, purpose, target, TokenKindLink)
}
func NewOTPRequest(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string) *Request {
return newRequest(accountRef, purpose, target, TokenKindOTP)
}
func (r *Request) WithTTL(ttl time.Duration) *Request {
r.Ttl = ttl
return r
}
func (r *Request) WithMaxRetries(maxRetries int) *Request {
r.MaxRetries = &maxRetries
return r
}
func (r *Request) WithCooldown(cooldown time.Duration) *Request {
r.Cooldown = &cooldown
return r
}
func (r *Request) WithIdempotencyKey(key string) *Request {
normalized := strings.TrimSpace(key)
if normalized == "" {
r.IdempotencyKey = nil
return r
}
r.IdempotencyKey = &normalized
return r
}
func (r *Request) WithContextRef(contextRef string) *Request {
r.ContextRef = strings.TrimSpace(contextRef)
return r
}
@@ -0,0 +1,257 @@
package verification
import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson"
)
func tokenFieldValue(tok *verificationmodel.VerificationToken, field string) any {
switch field {
case "_id":
return tok.ID
case "createdAt":
return tok.CreatedAt
case "verifyTokenHash":
return tok.VerifyTokenHash
case "salt":
return tok.Salt
case "usedAt":
return tok.UsedAt
case "expiresAt":
return tok.ExpiresAt
case "accountRef":
return tok.AccountRef
case "purpose":
return tok.Purpose
case "target":
return tok.Target
case "contextRef":
return tok.ContextRef
case "idempotencyKey":
if tok.IdempotencyKey == nil {
return nil
}
return *tok.IdempotencyKey
case "maxRetries":
return tok.MaxRetries
case "attempts":
return tok.Attempts
default:
return nil
}
}
func matchToken(query builder.Query, tok *verificationmodel.VerificationToken) bool {
if query == nil {
return true
}
return matchBsonD(query.BuildQuery(), tok)
}
func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool {
for _, elem := range filter {
if !matchElem(elem, tok) {
return false
}
}
return true
}
func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool {
switch elem.Key {
case "$and":
arr, ok := elem.Value.(bson.A)
if !ok {
return false
}
for _, sub := range arr {
d, ok := sub.(bson.D)
if !ok {
return false
}
if !matchBsonD(d, tok) {
return false
}
}
return true
default:
stored := tokenFieldValue(tok, elem.Key)
if opDoc, ok := elem.Value.(bson.M); ok {
return matchOperator(stored, opDoc)
}
return valuesEqual(stored, elem.Value)
}
}
func matchOperator(stored any, ops bson.M) bool {
for op, cmpVal := range ops {
switch op {
case "$gt":
if !cmpGt(stored, cmpVal) {
return false
}
case "$lt":
if !cmpLt(stored, cmpVal) {
return false
}
}
}
return true
}
func cmpGt(stored, cmpVal any) bool {
if si, ok := toInt(stored); ok {
if ci, ok := toInt(cmpVal); ok {
return si > ci
}
}
return timeGt(stored, cmpVal)
}
func cmpLt(stored, cmpVal any) bool {
if si, ok := toInt(stored); ok {
if ci, ok := toInt(cmpVal); ok {
return si < ci
}
}
return timeLt(stored, cmpVal)
}
func toInt(v any) (int, bool) {
switch iv := v.(type) {
case int:
return iv, true
case int64:
return int(iv), true
case int32:
return int(iv), true
}
return 0, false
}
func valuesEqual(a, b any) bool {
if b == nil {
return a == nil || a == (*time.Time)(nil)
}
switch av := a.(type) {
case *time.Time:
if av == nil {
return b == nil
}
if bv, ok := b.(*time.Time); ok {
return av.Equal(*bv)
}
return false
case bson.ObjectID:
if bv, ok := b.(bson.ObjectID); ok {
return av == bv
}
return false
case verificationmodel.VerificationPurpose:
if bv, ok := b.(verificationmodel.VerificationPurpose); ok {
return av == bv
}
return false
case string:
if bv, ok := b.(string); ok {
return av == bv
}
return false
}
return false
}
func timeGt(stored, cmpVal any) bool {
st, ok := toTime(stored)
if !ok {
return false
}
ct, ok := toTime(cmpVal)
if !ok {
return false
}
return st.After(ct)
}
func timeLt(stored, cmpVal any) bool {
st, ok := toTime(stored)
if !ok {
return false
}
ct, ok := toTime(cmpVal)
if !ok {
return false
}
return st.Before(ct)
}
func toTime(v any) (time.Time, bool) {
switch tv := v.(type) {
case time.Time:
return tv, true
case *time.Time:
if tv == nil {
return time.Time{}, false
}
return *tv, true
}
return time.Time{}, false
}
func applyPatch(tok *verificationmodel.VerificationToken, patchDoc bson.D) {
for _, op := range patchDoc {
switch op.Key {
case "$set":
fields, ok := op.Value.(bson.D)
if !ok {
continue
}
for _, f := range fields {
switch f.Key {
case "usedAt":
if t, ok := f.Value.(time.Time); ok {
tok.UsedAt = &t
}
}
}
case "$inc":
fields, ok := op.Value.(bson.D)
if !ok {
continue
}
for _, f := range fields {
switch f.Key {
case "attempts":
if v, ok := f.Value.(int); ok {
tok.Attempts += v
}
}
}
}
}
}
func cloneToken(src *verificationmodel.VerificationToken) *verificationmodel.VerificationToken {
dst := *src
if src.UsedAt != nil {
t := *src.UsedAt
dst.UsedAt = &t
}
if src.MaxRetries != nil {
v := *src.MaxRetries
dst.MaxRetries = &v
}
if src.Salt != nil {
s := *src.Salt
dst.Salt = &s
}
if src.IdempotencyKey != nil {
k := *src.IdempotencyKey
dst.IdempotencyKey = &k
}
return &dst
}
@@ -0,0 +1,14 @@
package verification
import (
"context"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB interface {
// template.DB[*verificationmodel.VerificationToken]
Create(ctx context.Context, request *Request) (verificationToken string, err error)
CreateWithResult(ctx context.Context, request *Request) (CreateResult, error)
Consume(ctx context.Context, accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, contextRef, verificationToken string) (*verificationmodel.VerificationToken, error)
}
@@ -0,0 +1,147 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"testing"
"time"
)
func TestConsume_MaxRetriesExceeded(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithMaxRetries(2)
raw, err := db.Create(ctx, r)
require.NoError(t, err)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
for _, tok := range repo.data {
tok.Attempts = 2
}
repo.mu.Unlock()
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded))
}
func TestConsume_UnderMaxRetriesSucceeds(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithMaxRetries(3)
raw, err := db.Create(ctx, r)
require.NoError(t, err)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
for _, tok := range repo.data {
tok.Attempts = 2
}
repo.mu.Unlock()
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestConsume_NoMaxRetriesIgnoresAttempts(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
for _, tok := range repo.data {
tok.Attempts = 100
}
repo.mu.Unlock()
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestConsume_WrongHashReturnsNotFound(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
_, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", "wrong-code")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenNotFound))
}
func TestConsume_WrongOTPAttemptsPersistAfterTransactionRollback(t *testing.T) {
db := newRollbackTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(
ctx,
NewOTPRequest(accountRef, verificationmodel.PurposeEmailChange, "new@example.com").
WithTTL(time.Hour).
WithMaxRetries(2),
)
require.NoError(t, err)
wrongCode := differentOTP(raw)
for attempt := 1; attempt <= 2; attempt++ {
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", wrongCode)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenNotFound))
assert.Equal(t, attempt, storedTokenAttempts(t, db))
}
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAttemptsExceeded))
}
func TestConsume_ContextMismatchReturnsNotFound(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
otherAccount := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, otherAccount, verificationmodel.PurposePasswordReset, "", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenNotFound))
}
func differentOTP(raw string) string {
if raw == "000000" {
return "111111"
}
return "000000"
}
func storedTokenAttempts(t *testing.T, db *verificationDB) int {
t.Helper()
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
defer repo.mu.Unlock()
require.Len(t, repo.data, 1)
for _, tok := range repo.data {
return tok.Attempts
}
return 0
}
@@ -0,0 +1,176 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"testing"
"time"
)
func TestCreate_CooldownBlocksCreation(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
_, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
r2 := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithCooldown(time.Minute)
_, err = db.Create(ctx, r2)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrCooldownActive))
}
func TestCreate_CooldownExpiresAllowsCreation(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
time.Sleep(2 * time.Millisecond)
r2 := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithCooldown(time.Millisecond)
secondRaw, err := db.Create(ctx, r2)
require.NoError(t, err)
assert.NotEqual(t, firstRaw, secondRaw)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", firstRaw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed))
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", secondRaw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestCreate_CooldownNilIgnored(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
_, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
}
func TestCreate_IdempotencyKeyReplayReturnsSameToken(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstReq := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key")
firstRaw, err := db.Create(ctx, firstReq)
require.NoError(t, err)
require.NotEmpty(t, firstRaw)
secondReq := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key")
secondRaw, err := db.Create(ctx, secondReq)
require.NoError(t, err)
assert.Equal(t, firstRaw, secondRaw)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
assert.Len(t, repo.data, 1)
repo.mu.Unlock()
}
func TestCreateWithResult_IdempotencyReplayReportsNotCreated(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key")
first, err := db.CreateWithResult(ctx, firstReq)
require.NoError(t, err)
require.NotEmpty(t, first.VerificationToken)
assert.True(t, first.Created)
secondReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key")
second, err := db.CreateWithResult(ctx, secondReq)
require.NoError(t, err)
assert.Equal(t, first.VerificationToken, second.VerificationToken)
assert.False(t, second.Created)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
assert.Len(t, repo.data, 1)
repo.mu.Unlock()
}
func TestCreateWithResult_IdempotencyReplayReusesExpiredStoredExpiry(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", -time.Minute).WithIdempotencyKey("same-key")
first, err := db.CreateWithResult(ctx, firstReq)
require.NoError(t, err)
require.NotEmpty(t, first.VerificationToken)
require.True(t, first.ExpiresAt.Before(time.Now().UTC()), "test must create an already expired token")
secondReq := req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key")
second, err := db.CreateWithResult(ctx, secondReq)
require.NoError(t, err)
assert.Equal(t, first.VerificationToken, second.VerificationToken)
assert.False(t, second.Created)
assert.True(t, second.ExpiresAt.Equal(first.ExpiresAt), "replay must reuse stored expiry")
assert.True(t, second.ExpiresAt.Before(time.Now().UTC()), "replay must not extend token validity")
}
func TestCreate_IdempotencyScopeIncludesTarget(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
r1 := req(accountRef, verificationmodel.PurposeEmailChange, "a@example.com", time.Hour).WithIdempotencyKey("same-key")
raw1, err := db.Create(ctx, r1)
require.NoError(t, err)
require.NotEmpty(t, raw1)
r2 := req(accountRef, verificationmodel.PurposeEmailChange, "b@example.com", time.Hour).WithIdempotencyKey("same-key")
raw2, err := db.Create(ctx, r2)
require.NoError(t, err)
require.NotEmpty(t, raw2)
assert.NotEqual(t, raw1, raw2)
t1, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw1)
require.NoError(t, err)
assert.Equal(t, "a@example.com", t1.Target)
t2, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw2)
require.NoError(t, err)
assert.Equal(t, "b@example.com", t2.Target)
}
func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) {
db := newTestVerificationDBWithFactory(t, &retryingTxFactory{})
ctx := context.Background()
accountRef := bson.NewObjectID()
r := req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour).
WithCooldown(time.Minute).
WithIdempotencyKey("retry-safe")
raw, err := db.Create(ctx, r)
require.NoError(t, err)
require.NotEmpty(t, raw)
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
require.Len(t, repo.data, 1)
for _, tok := range repo.data {
require.NotNil(t, tok.IdempotencyKey)
assert.Equal(t, "retry-safe", *tok.IdempotencyKey)
assert.Nil(t, tok.UsedAt)
assert.Equal(t, tok.VerifyTokenHash, tokenHash(raw))
}
repo.mu.Unlock()
}
@@ -0,0 +1,288 @@
package verification
import (
"context"
"errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"strings"
"testing"
"time"
)
func TestCreate_ReturnsRawToken(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
assert.NotEmpty(t, raw)
}
func TestCreate_TokenCanBeConsumed(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
assert.Equal(t, verificationmodel.PurposePasswordReset, tok.Purpose)
assert.NotNil(t, tok.UsedAt)
}
func TestConsume_ReturnsCorrectFields(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "new@example.com", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", raw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
assert.Equal(t, verificationmodel.PurposeEmailChange, tok.Purpose)
assert.Equal(t, "new@example.com", tok.Target)
}
func TestConsume_SecondConsumeFailsAlreadyUsed(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed),
"second consume should fail with already-used after usedAt is set")
}
func TestConsume_ExpiredTokenFails(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
// Create with a TTL that is already in the past.
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", -time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenExpired),
"expired token should return explicit expiry error")
}
func TestConsume_UnknownTokenFails(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
_, err := db.Consume(ctx, bson.NilObjectID, "", "", "nonexistent-token-value")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenNotFound))
}
func TestConsume_AccountActivationWithoutAccountRef(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
raw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
assert.Equal(t, verificationmodel.PurposeAccountActivation, tok.Purpose)
}
func TestCreate_InvalidatesPreviousToken(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
oldRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
newRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
assert.NotEqual(t, oldRaw, newRaw, "new token should differ from old one")
// Old token is no longer consumable — invalidated (usedAt set) by the second Create.
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", oldRaw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed),
"old token should return already-used after invalidation")
// New token works fine.
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", newRaw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
// First issue for the same verification scope.
firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour))
require.NoError(t, err)
// Second issue during resend should rotate token instead of failing with duplicate key.
secondRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour))
require.NoError(t, err)
assert.NotEqual(t, firstRaw, secondRaw)
// Old token becomes unusable after reissue.
_, err = db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", firstRaw)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed))
// New token is valid.
tok, err := db.Consume(ctx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", secondRaw)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
assert.Equal(t, verificationmodel.PurposeAccountActivation, tok.Purpose)
// Non-idempotent requests should still persist unique internal keys,
// preventing uniqueness collisions on (accountRef, purpose, target, idempotencyKey).
repo := db.Repository.(*memoryTokenRepository)
repo.mu.Lock()
defer repo.mu.Unlock()
keys := map[string]struct{}{}
for _, stored := range repo.data {
if stored.AccountRef != accountRef || stored.Purpose != verificationmodel.PurposeAccountActivation {
continue
}
require.NotNil(t, stored.IdempotencyKey)
assert.True(t, strings.HasPrefix(*stored.IdempotencyKey, "auto:"))
keys[*stored.IdempotencyKey] = struct{}{}
}
assert.Len(t, keys, 2)
}
func TestCreate_InvalidatesMultiplePreviousTokens(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
first, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
second, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
third, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", first)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "first should be invalidated/used")
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", second)
assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second should be invalidated/used")
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", third)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestCreate_DifferentPurposeNotInvalidated(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
resetRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
// Creating an activation token should NOT invalidate the password-reset token.
_, err = db.Create(ctx, req(accountRef, verificationmodel.PurposeAccountActivation, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", resetRaw)
require.NoError(t, err)
assert.Equal(t, verificationmodel.PurposePasswordReset, tok.Purpose)
}
func TestCreate_DifferentTargetNotInvalidated(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
firstRaw, err := db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "a@example.com", time.Hour))
require.NoError(t, err)
// Creating a token for a different target email should NOT invalidate the first.
_, err = db.Create(ctx, req(accountRef, verificationmodel.PurposeEmailChange, "b@example.com", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposeEmailChange, "", firstRaw)
require.NoError(t, err)
assert.Equal(t, "a@example.com", tok.Target)
}
func TestCreate_DifferentAccountNotInvalidated(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
account1 := bson.NewObjectID()
account2 := bson.NewObjectID()
raw1, err := db.Create(ctx, req(account1, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Create(ctx, req(account2, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, account1, verificationmodel.PurposePasswordReset, "", raw1)
require.NoError(t, err)
assert.Equal(t, account1, tok.AccountRef)
}
func TestCreate_AlreadyUsedTokenNotInvalidatedAgain(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
// Create and consume first token.
raw1, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
_, err = db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw1)
require.NoError(t, err)
// Create second — the already-consumed token should have usedAt set,
// so the invalidation query (usedAt == nil) should skip it.
// This tests that the PatchMany filter correctly excludes already-used tokens.
raw2, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw2)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
func TestCreate_ExpiredTokenNotInvalidated(t *testing.T) {
db := newTestVerificationDB(t)
ctx := context.Background()
accountRef := bson.NewObjectID()
// Create a token that is already expired.
_, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", -time.Hour))
require.NoError(t, err)
// Create a fresh one — invalidation should skip the expired token (expiresAt > now filter).
raw2, err := db.Create(ctx, req(accountRef, verificationmodel.PurposePasswordReset, "", time.Hour))
require.NoError(t, err)
tok, err := db.Consume(ctx, accountRef, verificationmodel.PurposePasswordReset, "", raw2)
require.NoError(t, err)
assert.Equal(t, accountRef, tok.AccountRef)
}
@@ -0,0 +1,103 @@
package verification
import (
"context"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"sync"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func newTestVerificationDB(t *testing.T) *verificationDB {
return newTestVerificationDBWithFactory(t, &passthroughTxFactory{})
}
func newRollbackTestVerificationDB(t *testing.T) *verificationDB {
t.Helper()
repo := newMemoryTokenRepository()
logger := zap.NewNop()
return &verificationDB{
DBImp: template.DBImp[*verificationmodel.VerificationToken]{
Logger: logger,
Repository: repo,
},
tf: &rollbackTxFactory{repo: repo},
}
}
func newTestVerificationDBWithFactory(t *testing.T, tf transaction.Factory) *verificationDB {
t.Helper()
repo := newMemoryTokenRepository()
logger := zap.NewNop()
return &verificationDB{
DBImp: template.DBImp[*verificationmodel.VerificationToken]{
Logger: logger,
Repository: repo,
},
tf: tf,
}
}
type passthroughTxFactory struct{}
func (*passthroughTxFactory) CreateTransaction() transaction.Transaction { return &passthroughTx{} }
type passthroughTx struct{}
func (*passthroughTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}
type retryingTxFactory struct{}
func (*retryingTxFactory) CreateTransaction() transaction.Transaction { return &retryingTx{} }
type retryingTx struct{}
func (*retryingTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
if _, err := cb(ctx); err != nil {
return nil, err
}
return cb(ctx)
}
type rollbackTxFactory struct {
mu sync.Mutex
repo *memoryTokenRepository
}
func (f *rollbackTxFactory) CreateTransaction() transaction.Transaction {
return &rollbackTx{factory: f}
}
type rollbackTx struct {
factory *rollbackTxFactory
}
func (tx *rollbackTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
tx.factory.mu.Lock()
defer tx.factory.mu.Unlock()
snapshot := tx.factory.repo.snapshot()
result, err := cb(ctx)
if err != nil {
tx.factory.repo.restore(snapshot)
return nil, err
}
return result, nil
}
func req(accountRef bson.ObjectID, purpose verificationmodel.VerificationPurpose, target string, ttl time.Duration) *Request {
return NewLinkRequest(accountRef, purpose, target).WithTTL(ttl)
}
func contextReq(accountRef bson.ObjectID, contextRef string) *Request {
return NewOTPRequest(accountRef, verificationmodel.PurposePasswordReset, "user@example.com").
WithTTL(time.Hour).
WithContextRef(contextRef)
}