Files
sxchnge/backend/product/verification/store/create.go
T

261 lines
7.4 KiB
Go

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))
}