249 lines
7.0 KiB
Go
249 lines
7.0 KiB
Go
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
|
|
}
|