From f8a3bef2e65d58b6b55f6677030bb8aa7aeb3acc Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 5 Feb 2026 20:51:03 +0100 Subject: [PATCH] implemented verifiaction db --- api/pkg/db/factory.go | 2 + api/pkg/db/internal/mongo/db.go | 6 ++ .../internal/mongo/verificationimp/consume.go | 72 ++++++++++++++++++ .../internal/mongo/verificationimp/create.go | 74 +++++++++++++++++++ .../db/internal/mongo/verificationimp/db.go | 49 ++++++++++++ .../internal/mongo/verificationimp/error.go | 7 ++ .../db/internal/mongo/verificationimp/hash.go | 11 +++ api/pkg/db/verification/verification.go | 28 +++++++ api/pkg/model/account.go | 11 +-- api/pkg/model/verificaton.go | 30 ++++++++ api/pkg/mservice/services.go | 1 + api/pkg/mutil/mzap/object.go | 4 + 12 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 api/pkg/db/internal/mongo/verificationimp/consume.go create mode 100644 api/pkg/db/internal/mongo/verificationimp/create.go create mode 100644 api/pkg/db/internal/mongo/verificationimp/db.go create mode 100644 api/pkg/db/internal/mongo/verificationimp/error.go create mode 100644 api/pkg/db/internal/mongo/verificationimp/hash.go create mode 100644 api/pkg/db/verification/verification.go create mode 100644 api/pkg/model/verificaton.go diff --git a/api/pkg/db/factory.go b/api/pkg/db/factory.go index 3bf4eb4a..27c89436 100644 --- a/api/pkg/db/factory.go +++ b/api/pkg/db/factory.go @@ -14,6 +14,7 @@ import ( "github.com/tech/sendico/pkg/db/refreshtokens" "github.com/tech/sendico/pkg/db/role" "github.com/tech/sendico/pkg/db/transaction" + "github.com/tech/sendico/pkg/db/verification" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" ) @@ -30,6 +31,7 @@ type Factory interface { NewInvitationsDB() (invitation.DB, error) NewRecipientsDB() (recipient.DB, error) NewPaymentMethodsDB() (paymethod.DB, error) + NewVerificationsDB() (verification.DB, error) NewRolesDB() (role.DB, error) NewPoliciesDB() (policy.DB, error) diff --git a/api/pkg/db/internal/mongo/db.go b/api/pkg/db/internal/mongo/db.go index 00139039..dc65ee8f 100755 --- a/api/pkg/db/internal/mongo/db.go +++ b/api/pkg/db/internal/mongo/db.go @@ -23,6 +23,7 @@ import ( "github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb" "github.com/tech/sendico/pkg/db/internal/mongo/rolesdb" "github.com/tech/sendico/pkg/db/internal/mongo/transactionimp" + "github.com/tech/sendico/pkg/db/internal/mongo/verificationimp" "github.com/tech/sendico/pkg/db/invitation" "github.com/tech/sendico/pkg/db/organization" "github.com/tech/sendico/pkg/db/paymethod" @@ -32,6 +33,7 @@ import ( "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/role" "github.com/tech/sendico/pkg/db/transaction" + "github.com/tech/sendico/pkg/db/verification" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" @@ -246,6 +248,10 @@ func (db *DB) NewRolesDB() (role.DB, error) { return rolesdb.Create(db.logger, db.db()) } +func (db *DB) NewVerificationsDB() (verification.DB, error) { + return verificationimp.Create(db.logger, db.db(), db.TransactionFactory()) +} + func (db *DB) TransactionFactory() transaction.Factory { return transactionimp.CreateFactory(db.client) } diff --git a/api/pkg/db/internal/mongo/verificationimp/consume.go b/api/pkg/db/internal/mongo/verificationimp/consume.go new file mode 100644 index 00000000..941cca91 --- /dev/null +++ b/api/pkg/db/internal/mongo/verificationimp/consume.go @@ -0,0 +1,72 @@ +package verificationimp + +import ( + "context" + "errors" + "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" + "go.uber.org/zap" +) + +func (db *verificationDB) Consume( + ct context.Context, + rawToken string, +) (*model.VerificationToken, error) { + + hash := tokenHash(rawToken) + now := time.Now().UTC() + + // 1) Пытаемся атомарно использовать токен + filter := repository.Query().And( + repository.Filter("verifyTokenHash", hash), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), + ) + + t, err := 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 { + if errors.Is(err, merrors.ErrNoData) { + // normal behaviour + db.Logger.Debug("Token hash not found", zap.Error(err), zap.String("hash", hash)) + return nil, wrap(verification.ErrTokenNotFound, err.Error()) + } + db.Logger.Warn("Failed to check token", zap.Error(err), zap.String("hash", hash)) + return nil, err + } + if existing.UsedAt != nil { + db.Logger.Debug("Token has already been used", zap.String("hash", hash), zap.Time("used_at", *existing.UsedAt)) + return nil, wrap(verification.ErrTokenAlreadyUsed, "db: token already used") + } + + if existing.ExpiresAt.Before(now) { + db.Logger.Debug("Token has already expired", zap.String("hash", hash), zap.Time("expired_at", existing.ExpiresAt)) + return nil, wrap(verification.ErrTokenExpired, "db: token expired") + } + + 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)) + return nil, err + } + + return &existing, nil + }, + ) + if err != nil { + return nil, err + } + res, ok := t.(*model.VerificationToken) + if !ok { + return nil, merrors.Internal("unexpexted token type") + } + + return res, nil +} diff --git a/api/pkg/db/internal/mongo/verificationimp/create.go b/api/pkg/db/internal/mongo/verificationimp/create.go new file mode 100644 index 00000000..adf2133f --- /dev/null +++ b/api/pkg/db/internal/mongo/verificationimp/create.go @@ -0,0 +1,74 @@ +package verificationimp + +import ( + "context" + "crypto/rand" + "encoding/base64" + "time" + + "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 newVerificationToken( + accountRef bson.ObjectID, + purpose model.VerificationPurpose, + target string, + ttl time.Duration, +) (*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), + } + + return token, rawToken, nil +} + +func (db *verificationDB) Create( + ctx context.Context, + accountRef bson.ObjectID, + purpose model.VerificationPurpose, + target string, + ttl time.Duration, +) (string, error) { + + logFields := []zap.Field{ + zap.String("purpose", string(purpose)), zap.Duration("ttl", ttl), + mzap.AccRef(accountRef), zap.String("target", target), + } + + token, raw, err := newVerificationToken(accountRef, purpose, target, ttl) + if err != nil { + db.Logger.Warn("Failed to generate verification token", append(logFields, zap.Error(err))...) + return "", err + } + + 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 +} diff --git a/api/pkg/db/internal/mongo/verificationimp/db.go b/api/pkg/db/internal/mongo/verificationimp/db.go new file mode 100644 index 00000000..c44e3bcf --- /dev/null +++ b/api/pkg/db/internal/mongo/verificationimp/db.go @@ -0,0 +1,49 @@ +package verificationimp + +import ( + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/db/template" + "github.com/tech/sendico/pkg/db/transaction" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type verificationDB struct { + template.DBImp[*model.VerificationToken] + tf transaction.Factory +} + +func Create( + logger mlogger.Logger, + db *mongo.Database, + tf transaction.Factory, +) (*verificationDB, error) { + p := &verificationDB{ + DBImp: *template.Create[*model.VerificationToken](logger, mservice.VerificationTokens, db), + tf: tf, + } + + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "verifyTokenHash", Sort: ri.Asc}}, + Unique: true, + Name: "unique_token_hash", + }); err != nil { + p.Logger.Error("Failed to create unique verifyTokenHash index", zap.Error(err)) + return nil, err + } + + ttl := int32(2678400) // 30 days + if err := p.Repository.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, + TTL: &ttl, + Name: "ttl_expires_at", + }); err != nil { + p.Logger.Error("Failed to create TTL index on expiresAt", zap.Error(err)) + return nil, err + } + + return p, nil +} diff --git a/api/pkg/db/internal/mongo/verificationimp/error.go b/api/pkg/db/internal/mongo/verificationimp/error.go new file mode 100644 index 00000000..f0d1bf67 --- /dev/null +++ b/api/pkg/db/internal/mongo/verificationimp/error.go @@ -0,0 +1,7 @@ +package verificationimp + +import "fmt" + +func wrap(err error, msg string) error { + return fmt.Errorf("%s: %w", msg, err) +} diff --git a/api/pkg/db/internal/mongo/verificationimp/hash.go b/api/pkg/db/internal/mongo/verificationimp/hash.go new file mode 100644 index 00000000..2e755c44 --- /dev/null +++ b/api/pkg/db/internal/mongo/verificationimp/hash.go @@ -0,0 +1,11 @@ +package verificationimp + +import ( + "crypto/sha256" + "encoding/base64" +) + +func tokenHash(rawToken string) string { + hash := sha256.Sum256([]byte(rawToken)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} diff --git a/api/pkg/db/verification/verification.go b/api/pkg/db/verification/verification.go new file mode 100644 index 00000000..a04e86eb --- /dev/null +++ b/api/pkg/db/verification/verification.go @@ -0,0 +1,28 @@ +package verification + +import ( + "context" + "errors" + "time" + + "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + ErrTokenNotFound = errors.New("verification token not found") + ErrTokenAlreadyUsed = errors.New("verification token already used") + ErrTokenExpired = errors.New("verification token expired") +) + +type DB interface { + // template.DB[*model.VerificationToken] + Create( + ctx context.Context, + accountRef bson.ObjectID, + purpose model.VerificationPurpose, + target string, + ttl time.Duration, + ) (rawToken string, err error) + Consume(ctx context.Context, rawToken string) (*model.VerificationToken, error) +} diff --git a/api/pkg/model/account.go b/api/pkg/model/account.go index ea0d41ff..48dbb829 100755 --- a/api/pkg/model/account.go +++ b/api/pkg/model/account.go @@ -1,6 +1,8 @@ package model import ( + "time" + "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/v2/bson" @@ -27,11 +29,10 @@ type AccountPublic struct { } type Account struct { - AccountPublic `bson:",inline" json:",inline"` - EmailBackup string `bson:"emailBackup" json:"emailBackup"` - Password string `bson:"password" json:"password"` - ResetPasswordToken string `bson:"resetPasswordToken" json:"resetPasswordToken"` - VerifyToken string `bson:"verifyToken" json:"verifyToken"` + AccountPublic `bson:",inline" json:",inline"` + EmailBackup string `bson:"emailBackup" json:"-"` + Password string `bson:"password" json:"-"` // password hash + EmailVerifiedAt *time.Time `bson:"emailVerifiedAt,omitempty" json:"-"` } func (a *Account) HashPassword() error { diff --git a/api/pkg/model/verificaton.go b/api/pkg/model/verificaton.go new file mode 100644 index 00000000..c38440a3 --- /dev/null +++ b/api/pkg/model/verificaton.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type VerificationPurpose string + +const ( + PurposeAccountActivation VerificationPurpose = "account_activation" + PurposeEmailChange VerificationPurpose = "email_change" + PurposePasswordReset VerificationPurpose = "password_reset" + PurposeSensitiveAction VerificationPurpose = "sensitive_action" + PurposeMagicLogin VerificationPurpose = "magic_login" +) + +type VerificationToken struct { + storable.Base `bson:",inline" json:",inline"` + ArchivableBase `bson:",inline" json:",inline"` + + Target string `bson:"target,omitempty" json:"target"` + AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` + Purpose VerificationPurpose `bson:"purpose" json:"purpose"` + VerifyTokenHash string `bson:"verifyTokenHash" json:"-"` + UsedAt *time.Time `bson:"usedAt,omitempty" json:"-"` + ExpiresAt time.Time `bson:"expiresAt" json:"-"` +} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 010aa87d..53309ea9 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -50,6 +50,7 @@ const ( Storage Type = "storage" // Represents statuses of tasks or projects TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway Tenants Type = "tenants" // Represents tenants managed in the system + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system Wallets Type = "wallets" // Represents workflows for tasks or projects Workflows Type = "workflows" // Represents workflows for tasks or projects ) diff --git a/api/pkg/mutil/mzap/object.go b/api/pkg/mutil/mzap/object.go index fac4b695..e36a195a 100644 --- a/api/pkg/mutil/mzap/object.go +++ b/api/pkg/mutil/mzap/object.go @@ -13,3 +13,7 @@ func ObjRef(name string, objRef bson.ObjectID) zap.Field { func StorableRef(obj storable.Storable) zap.Field { return ObjRef(obj.Collection()+"_ref", *obj.GetID()) } + +func AccRef(accountRef bson.ObjectID) zap.Field { + return ObjRef("account_ref", accountRef) +}