implemented verifiaction db

This commit is contained in:
Stephan D
2026-02-05 20:51:03 +01:00
parent 4639b2c610
commit f8a3bef2e6
12 changed files with 290 additions and 5 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/tech/sendico/pkg/db/refreshtokens" "github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/db/role" "github.com/tech/sendico/pkg/db/role"
"github.com/tech/sendico/pkg/db/transaction" "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/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
) )
@@ -30,6 +31,7 @@ type Factory interface {
NewInvitationsDB() (invitation.DB, error) NewInvitationsDB() (invitation.DB, error)
NewRecipientsDB() (recipient.DB, error) NewRecipientsDB() (recipient.DB, error)
NewPaymentMethodsDB() (paymethod.DB, error) NewPaymentMethodsDB() (paymethod.DB, error)
NewVerificationsDB() (verification.DB, error)
NewRolesDB() (role.DB, error) NewRolesDB() (role.DB, error)
NewPoliciesDB() (policy.DB, error) NewPoliciesDB() (policy.DB, error)

View File

@@ -23,6 +23,7 @@ import (
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb" "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/rolesdb"
"github.com/tech/sendico/pkg/db/internal/mongo/transactionimp" "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/invitation"
"github.com/tech/sendico/pkg/db/organization" "github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/db/paymethod" "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/repository"
"github.com/tech/sendico/pkg/db/role" "github.com/tech/sendico/pkg/db/role"
"github.com/tech/sendico/pkg/db/transaction" "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/mlogger"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
@@ -246,6 +248,10 @@ func (db *DB) NewRolesDB() (role.DB, error) {
return rolesdb.Create(db.logger, db.db()) 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 { func (db *DB) TransactionFactory() transaction.Factory {
return transactionimp.CreateFactory(db.client) return transactionimp.CreateFactory(db.client)
} }

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
package verificationimp
import "fmt"
func wrap(err error, msg string) error {
return fmt.Errorf("%s: %w", msg, err)
}

View File

@@ -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[:])
}

View File

@@ -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)
}

View File

@@ -1,6 +1,8 @@
package model package model
import ( import (
"time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -28,10 +30,9 @@ type AccountPublic struct {
type Account struct { type Account struct {
AccountPublic `bson:",inline" json:",inline"` AccountPublic `bson:",inline" json:",inline"`
EmailBackup string `bson:"emailBackup" json:"emailBackup"` EmailBackup string `bson:"emailBackup" json:"-"`
Password string `bson:"password" json:"password"` Password string `bson:"password" json:"-"` // password hash
ResetPasswordToken string `bson:"resetPasswordToken" json:"resetPasswordToken"` EmailVerifiedAt *time.Time `bson:"emailVerifiedAt,omitempty" json:"-"`
VerifyToken string `bson:"verifyToken" json:"verifyToken"`
} }
func (a *Account) HashPassword() error { func (a *Account) HashPassword() error {

View File

@@ -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:"-"`
}

View File

@@ -50,6 +50,7 @@ const (
Storage Type = "storage" // Represents statuses of tasks or projects Storage Type = "storage" // Represents statuses of tasks or projects
TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway
Tenants Type = "tenants" // Represents tenants managed in the system 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 Wallets Type = "wallets" // Represents workflows for tasks or projects
Workflows Type = "workflows" // Represents workflows for tasks or projects Workflows Type = "workflows" // Represents workflows for tasks or projects
) )

View File

@@ -13,3 +13,7 @@ func ObjRef(name string, objRef bson.ObjectID) zap.Field {
func StorableRef(obj storable.Storable) zap.Field { func StorableRef(obj storable.Storable) zap.Field {
return ObjRef(obj.Collection()+"_ref", *obj.GetID()) return ObjRef(obj.Collection()+"_ref", *obj.GetID())
} }
func AccRef(accountRef bson.ObjectID) zap.Field {
return ObjRef("account_ref", accountRef)
}