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

@@ -6,68 +6,146 @@ import (
"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"
mutil "github.com/tech/sendico/pkg/mutil/db"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *verificationDB) Consume(
ct context.Context,
accountRef bson.ObjectID,
purpose model.VerificationPurpose,
rawToken string,
) (*model.VerificationToken, error) {
hash := tokenHash(rawToken)
now := time.Now().UTC()
// 1) Find token by hash (do NOT filter by usedAt/expiresAt here),
// otherwise you can't distinguish "used/expired" from "not found".
filter := repository.Query().And(
repository.Filter("verifyTokenHash", hash),
)
t, e := db.tf.CreateTransaction().Execute(
ct,
func(ctx context.Context) (any, error) {
var existing model.VerificationToken
if err := db.DBImp.FindOne(ctx, filter, &existing); err != nil {
// 1) Load active tokens for this context
activeFilter := repository.Query().And(
repository.Filter("accountRef", accountRef),
repository.Filter("purpose", purpose),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
)
tokens, err := mutil.GetObjects[model.VerificationToken](
ctx, db.Logger, activeFilter, nil, db.DBImp.Repository,
)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
db.Logger.Debug("Token hash not found", zap.Error(err), zap.String("hash", hash))
db.Logger.Debug("No tokens found", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose)))
return nil, verification.ErorrTokenNotFound()
}
db.Logger.Warn("Failed to check token", zap.Error(err), zap.String("hash", hash))
db.Logger.Warn("Failed to load active tokens", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose)))
return nil, err
}
// 2) Semantic checks
if existing.UsedAt != nil {
db.Logger.Debug(
"Token has already been used",
zap.String("hash", hash),
zap.Time("used_at", *existing.UsedAt),
)
if len(tokens) == 0 {
db.Logger.Debug("No tokens found", zap.Error(err), mzap.AccRef(accountRef), zap.String("purpose", string(purpose)))
return nil, verification.ErorrTokenNotFound()
}
// 2) Find matching token via hasher (OTP or Magic — doesn't matter)
var token *model.VerificationToken
for i := range tokens {
t := &tokens[i]
hash := hasherFor(t).Hash(rawToken, t)
if hash == t.VerifyTokenHash {
token = t
break
}
}
if token == nil {
// wrong code/token → increment attempts
for _, t := range tokens {
_, _ = db.DBImp.PatchMany(
ctx,
repository.IDFilter(t.ID),
repository.Patch().Inc(repository.Field("attempts"), 1),
)
}
return nil, verification.ErorrTokenNotFound()
}
// 3) Static checks
if token.UsedAt != nil {
return nil, verification.ErorrTokenAlreadyUsed()
}
if !existing.ExpiresAt.After(now) { // includes equal time edge-case
db.Logger.Debug(
"Token has already expired",
zap.String("hash", hash),
zap.Time("expired_at", existing.ExpiresAt),
)
if !token.ExpiresAt.After(now) {
return nil, verification.ErorrTokenExpired()
}
if token.MaxRetries != nil && token.Attempts >= *token.MaxRetries {
return nil, verification.ErrorTokenAttemptsExceeded()
}
// 3) Mark as used
existing.UsedAt = &now
if err := db.DBImp.Update(ctx, &existing); err != nil {
db.Logger.Warn("Failed to consume token", zap.Error(err), zap.String("hash", hash))
// 4) Atomic consume
consumeFilter := repository.Query().And(
repository.IDFilter(token.ID),
repository.Filter("accountRef", accountRef),
repository.Filter("purpose", purpose),
repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
)
if token.MaxRetries != nil {
consumeFilter = consumeFilter.And(
repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries),
)
}
updated, err := db.DBImp.PatchMany(
ctx,
consumeFilter,
repository.Patch().Set(repository.Field("usedAt"), now),
)
if err != nil {
return nil, err
}
return &existing, nil
if updated == 1 {
token.UsedAt = &now
return token, nil
}
// 5) Consume failed → increment attempts
_, _ = db.DBImp.PatchMany(
ctx,
repository.IDFilter(token.ID),
repository.Patch().Inc(repository.Field("attempts"), 1),
)
// 6) Re-check state
var fresh model.VerificationToken
if err := db.DBImp.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil {
return nil, merrors.Internal("failed to re-check token state")
}
if fresh.UsedAt != nil {
return nil, verification.ErorrTokenAlreadyUsed()
}
if !fresh.ExpiresAt.After(now) {
return nil, verification.ErorrTokenExpired()
}
if fresh.MaxRetries != nil && fresh.Attempts >= *fresh.MaxRetries {
return nil, verification.ErrorTokenAttemptsExceeded()
}
return nil, verification.ErorrTokenNotFound()
},
)
if e != nil {
return nil, e
}
@@ -76,6 +154,5 @@ func (db *verificationDB) Consume(
if !ok {
return nil, merrors.Internal("unexpected token type")
}
return res, nil
}