Organize backend into product executable and shared Go modules
This commit is contained in:
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user