unified code verification service

This commit is contained in:
Stephan D
2026-02-10 01:55:33 +01:00
parent 76c3bfdea9
commit 7f540671c1
120 changed files with 1863 additions and 1394 deletions

View File

@@ -2,95 +2,224 @@ package verificationimp
import (
"context"
"crypto/rand"
"encoding/base64"
"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"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
const verificationTokenBytes = 32
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(
accountRef bson.ObjectID,
purpose model.VerificationPurpose,
target string,
ttl time.Duration,
request *verification.Request,
idempotencyKey string,
hasIdempotency bool,
) (*model.VerificationToken, string, error) {
raw := make([]byte, verificationTokenBytes)
if _, err := rand.Read(raw); err != nil {
return nil, "", err
}
rawToken := base64.RawURLEncoding.EncodeToString(raw)
hashStr := tokenHash(rawToken)
now := time.Now().UTC()
token := &model.VerificationToken{
AccountRef: accountRef,
Purpose: purpose,
Target: target,
VerifyTokenHash: hashStr,
UsedAt: nil,
ExpiresAt: now.Add(ttl),
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
}
}
}
return token, rawToken, nil
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,
accountRef bson.ObjectID,
purpose model.VerificationPurpose,
target string,
ttl time.Duration,
request *verification.Request,
) (string, error) {
logFields := []zap.Field{
zap.String("purpose", string(purpose)), zap.Duration("ttl", ttl),
mzap.AccRef(accountRef), zap.String("target", target),
if request == nil {
return "", merrors.Internal("nil request")
}
token, raw, err := newVerificationToken(accountRef, purpose, target, ttl)
idempotencyKey, hasIdempotency := normalizedIdempotencyKey(request.IdempotencyKey)
token, raw, err := newVerificationToken(request, idempotencyKey, hasIdempotency)
if err != nil {
db.Logger.Warn("Failed to generate verification token", append(logFields, zap.Error(err))...)
return "", err
}
// Invalidate any active tokens for the same (accountRef, purpose, target).
now := time.Now().UTC()
invalidated, err := db.DBImp.PatchMany(ctx,
repository.Query().And(
repository.Filter("accountRef", accountRef),
repository.Filter("purpose", purpose),
repository.Filter("target", target),
_, 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),
),
repository.Patch().Set(repository.Field("usedAt"), 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 {
db.Logger.Warn("Failed to invalidate previous tokens", append(logFields, zap.Error(err))...)
return "", err
}
if invalidated > 0 {
db.Logger.Debug("Invalidated previous tokens", append(logFields, zap.Int("count", invalidated))...)
}
if err := db.DBImp.Create(ctx, token); err != nil {
db.Logger.Warn("Failed to persist verification token", append(logFields, zap.Error(err))...)
return "", err
}
db.Logger.Debug("Verification token created", append(logFields, zap.String("hash", token.VerifyTokenHash))...)
return raw, nil
}