package verification import ( "context" "errors" "time" verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" "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), targetFilter(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), targetFilter(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, err = syntheticIdempotencyKey() if err != nil { return CreateResult{}, err } 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 } // Older records omit an empty target; Mongo does not equate a missing field // with the empty string. Match both forms for cooldown, invalidation and replay. func targetFilter(target string) builder.Query { if target != "" { return repository.Filter("target", target) } return repository.Query().Or(repository.Filter("target", ""), repository.Filter("target", nil)) }