unified code verification service
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user