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,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
|
||||
}
|
||||
Reference in new issue
Block a user