226 lines
5.5 KiB
Go
226 lines
5.5 KiB
Go
package verificationimp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tech/sendico/pkg/db/repository"
|
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
|
"github.com/tech/sendico/pkg/db/verification"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/model"
|
|
)
|
|
|
|
func normalizedIdempotencyKey(value *string) (string, bool) {
|
|
if value == nil {
|
|
return "", false
|
|
}
|
|
key := strings.TrimSpace(*value)
|
|
if key == "" {
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
func idempotencyFilter(
|
|
request *verification.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 hashFilter(hash string) builder.Query {
|
|
return repository.Filter("verifyTokenHash", hash)
|
|
}
|
|
|
|
func idempotencySeed(request *verification.Request, idempotencyKey string) string {
|
|
return strings.Join([]string{
|
|
request.AccountRef.Hex(),
|
|
string(request.Purpose),
|
|
request.Target,
|
|
request.Kind,
|
|
idempotencyKey,
|
|
}, "|")
|
|
}
|
|
|
|
func newVerificationToken(
|
|
request *verification.Request,
|
|
idempotencyKey string,
|
|
hasIdempotency bool,
|
|
) (*model.VerificationToken, string, error) {
|
|
|
|
now := time.Now().UTC()
|
|
|
|
var (
|
|
raw string
|
|
hash string
|
|
salt *string
|
|
err error
|
|
)
|
|
|
|
switch request.Kind {
|
|
case verification.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 := &model.VerificationToken{
|
|
AccountRef: request.AccountRef,
|
|
Purpose: request.Purpose,
|
|
Target: request.Target,
|
|
IdempotencyKey: nil,
|
|
VerifyTokenHash: hash,
|
|
Salt: salt,
|
|
UsedAt: nil,
|
|
ExpiresAt: now.Add(request.Ttl),
|
|
MaxRetries: request.MaxRetries,
|
|
}
|
|
if hasIdempotency {
|
|
token.IdempotencyKey = &idempotencyKey
|
|
}
|
|
|
|
return token, raw, nil
|
|
}
|
|
|
|
func (db *verificationDB) Create(
|
|
ctx context.Context,
|
|
request *verification.Request,
|
|
) (string, error) {
|
|
|
|
if request == nil {
|
|
return "", merrors.Internal("nil request")
|
|
}
|
|
|
|
idempotencyKey, hasIdempotency := normalizedIdempotencyKey(request.IdempotencyKey)
|
|
|
|
token, raw, err := newVerificationToken(request, idempotencyKey, hasIdempotency)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
_, err = db.tf.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
|
now := time.Now().UTC()
|
|
|
|
baseFilter := repository.Query().And(
|
|
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),
|
|
)
|
|
|
|
// Optional idempotency key support for safe retries.
|
|
if hasIdempotency {
|
|
var sameToken model.VerificationToken
|
|
err := db.DBImp.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken)
|
|
switch {
|
|
case err == nil:
|
|
// Same hash means the same Create operation already succeeded.
|
|
return nil, nil
|
|
case errors.Is(err, merrors.ErrNoData):
|
|
default:
|
|
return nil, err
|
|
}
|
|
|
|
var existing model.VerificationToken
|
|
err = db.DBImp.FindOne(tx, idempotencyFilter(request, idempotencyKey), &existing)
|
|
switch {
|
|
case err == nil:
|
|
// Existing request with the same idempotency scope has already succeeded.
|
|
return nil, 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)
|
|
|
|
cooldownFilter := baseFilter.And(
|
|
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
|
|
)
|
|
|
|
var recent model.VerificationToken
|
|
err := db.DBImp.FindOne(tx, cooldownFilter, &recent)
|
|
switch {
|
|
case err == nil:
|
|
return nil, verification.ErrorCooldownActive()
|
|
case errors.Is(err, merrors.ErrNoData):
|
|
default:
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 2) Invalidate active tokens for this context
|
|
if _, err := db.DBImp.PatchMany(
|
|
tx,
|
|
baseFilter,
|
|
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) {
|
|
var sameToken model.VerificationToken
|
|
findErr := db.DBImp.FindOne(tx, hashFilter(token.VerifyTokenHash), &sameToken)
|
|
switch {
|
|
case findErr == nil:
|
|
return nil, nil
|
|
case errors.Is(findErr, merrors.ErrNoData):
|
|
default:
|
|
return nil, findErr
|
|
}
|
|
|
|
var existing model.VerificationToken
|
|
findErr = db.DBImp.FindOne(tx, idempotencyFilter(request, idempotencyKey), &existing)
|
|
switch {
|
|
case findErr == nil:
|
|
return nil, nil
|
|
case errors.Is(findErr, merrors.ErrNoData):
|
|
default:
|
|
return nil, findErr
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
return nil, nil
|
|
})
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return raw, nil
|
|
}
|