fixed verification code
This commit is contained in:
@@ -152,6 +152,12 @@ func BadRequest(logger mlogger.Logger, source mservice.Type, err, hint string) h
|
||||
}
|
||||
}
|
||||
|
||||
func Gone(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusGone, err, hint)
|
||||
}
|
||||
}
|
||||
|
||||
func BadQueryParam(logger mlogger.Logger, source mservice.Type, param string, err error) http.HandlerFunc {
|
||||
return BadRequest(logger, source, "invalid_query_parameter", fmt.Sprintf("Failed to parse '%s': %v", param, err))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,5 @@ import (
|
||||
type DB interface {
|
||||
template.DB[*model.Account]
|
||||
GetByEmail(ctx context.Context, email string) (*model.Account, error)
|
||||
GetByToken(ctx context.Context, email string) (*model.Account, error)
|
||||
GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package accountdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func (db *AccountDB) GetByToken(ctx context.Context, email string) (*model.Account, error) {
|
||||
var account model.Account
|
||||
return &account, db.FindOne(ctx, repository.Query().Filter(repository.Field("verifyToken"), email), &account)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -21,36 +20,45 @@ func (db *verificationDB) Consume(
|
||||
hash := tokenHash(rawToken)
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 1) Пытаемся атомарно использовать токен
|
||||
// 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),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
)
|
||||
|
||||
t, err := db.tf.CreateTransaction().Execute(
|
||||
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 {
|
||||
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())
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
db.Logger.Warn("Failed to check token", zap.Error(err), zap.String("hash", hash))
|
||||
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))
|
||||
return nil, wrap(verification.ErrTokenAlreadyUsed, "db: token already used")
|
||||
db.Logger.Debug(
|
||||
"Token has already been used",
|
||||
zap.String("hash", hash),
|
||||
zap.Time("used_at", *existing.UsedAt),
|
||||
)
|
||||
return nil, verification.ErorrTokenAlreadyUsed()
|
||||
}
|
||||
|
||||
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")
|
||||
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),
|
||||
)
|
||||
return nil, verification.ErorrTokenExpired()
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -60,12 +68,13 @@ func (db *verificationDB) Consume(
|
||||
return &existing, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
res, ok := t.(*model.VerificationToken)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("unexpexted token type")
|
||||
return nil, merrors.Internal("unexpected token type")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -63,6 +65,26 @@ func (db *verificationDB) Create(
|
||||
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),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
),
|
||||
repository.Patch().Set(repository.Field("usedAt"), now),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package verificationimp
|
||||
|
||||
import "fmt"
|
||||
|
||||
func wrap(err error, msg string) error {
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
621
api/pkg/db/internal/mongo/verificationimp/verification_test.go
Normal file
621
api/pkg/db/internal/mongo/verificationimp/verification_test.go
Normal file
@@ -0,0 +1,621 @@
|
||||
package verificationimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/db/template"
|
||||
"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/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestVerificationDB(t *testing.T) *verificationDB {
|
||||
t.Helper()
|
||||
repo := newMemoryTokenRepository()
|
||||
logger := zap.NewNop()
|
||||
return &verificationDB{
|
||||
DBImp: template.DBImp[*model.VerificationToken]{
|
||||
Logger: logger,
|
||||
Repository: repo,
|
||||
},
|
||||
tf: &passthroughTxFactory{},
|
||||
}
|
||||
}
|
||||
|
||||
// passthroughTxFactory executes callbacks directly without a real transaction.
|
||||
type passthroughTxFactory struct{}
|
||||
|
||||
func (*passthroughTxFactory) CreateTransaction() transaction.Transaction { return &passthroughTx{} }
|
||||
|
||||
type passthroughTx struct{}
|
||||
|
||||
func (*passthroughTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
return cb(ctx)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// in-memory repository for VerificationToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type memoryTokenRepository struct {
|
||||
mu sync.Mutex
|
||||
data map[bson.ObjectID]*model.VerificationToken
|
||||
order []bson.ObjectID
|
||||
seq int
|
||||
}
|
||||
|
||||
func newMemoryTokenRepository() *memoryTokenRepository {
|
||||
return &memoryTokenRepository{data: make(map[bson.ObjectID]*model.VerificationToken)}
|
||||
}
|
||||
|
||||
func (m *memoryTokenRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
tok, ok := obj.(*model.VerificationToken)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected VerificationToken")
|
||||
}
|
||||
id := tok.GetID()
|
||||
if id == nil || *id == bson.NilObjectID {
|
||||
m.seq++
|
||||
tok.SetID(bson.NewObjectID())
|
||||
id = tok.GetID()
|
||||
}
|
||||
if _, exists := m.data[*id]; exists {
|
||||
return merrors.DataConflict("token already exists")
|
||||
}
|
||||
m.data[*id] = cloneToken(tok)
|
||||
m.order = append(m.order, *id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryTokenRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
tok, ok := m.data[id]
|
||||
if !ok {
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
dst := result.(*model.VerificationToken)
|
||||
*dst = *cloneToken(tok)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryTokenRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, id := range m.order {
|
||||
tok := m.data[id]
|
||||
if tok != nil && matchToken(query, tok) {
|
||||
dst := result.(*model.VerificationToken)
|
||||
*dst = *cloneToken(tok)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (m *memoryTokenRepository) Update(_ context.Context, obj storable.Storable) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
tok := obj.(*model.VerificationToken)
|
||||
id := tok.GetID()
|
||||
if id == nil {
|
||||
return merrors.InvalidArgument("id required")
|
||||
}
|
||||
if _, exists := m.data[*id]; !exists {
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
m.data[*id] = cloneToken(tok)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryTokenRepository) PatchMany(_ context.Context, filter builder.Query, patch builder.Patch) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
patchDoc := patch.Build()
|
||||
count := 0
|
||||
for _, id := range m.order {
|
||||
tok := m.data[id]
|
||||
if tok != nil && matchToken(filter, tok) {
|
||||
applyPatch(tok, patchDoc)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// stubs — not exercised by verification DB but required by the interface
|
||||
|
||||
func (m *memoryTokenRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) InsertMany(ctx context.Context, objs []storable.Storable) error {
|
||||
for _, o := range objs {
|
||||
if err := m.Insert(ctx, o, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *memoryTokenRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error {
|
||||
return merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) Delete(_ context.Context, id bson.ObjectID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, id)
|
||||
return nil
|
||||
}
|
||||
func (m *memoryTokenRepository) DeleteMany(context.Context, builder.Query) error {
|
||||
return merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) CreateIndex(*ri.Definition) error { return nil }
|
||||
func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson.ObjectID, error) {
|
||||
return nil, merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("not needed")
|
||||
}
|
||||
func (m *memoryTokenRepository) Collection() string { return mservice.VerificationTokens }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bson.D query evaluation for VerificationToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tokenFieldValue returns the stored value for a given BSON field name.
|
||||
func tokenFieldValue(tok *model.VerificationToken, field string) any {
|
||||
switch field {
|
||||
case "verifyTokenHash":
|
||||
return tok.VerifyTokenHash
|
||||
case "usedAt":
|
||||
return tok.UsedAt
|
||||
case "expiresAt":
|
||||
return tok.ExpiresAt
|
||||
case "accountRef":
|
||||
return tok.AccountRef
|
||||
case "purpose":
|
||||
return tok.Purpose
|
||||
case "target":
|
||||
return tok.Target
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// matchToken evaluates a bson.D filter against a token.
|
||||
func matchToken(query builder.Query, tok *model.VerificationToken) bool {
|
||||
if query == nil {
|
||||
return true
|
||||
}
|
||||
return matchBsonD(query.BuildQuery(), tok)
|
||||
}
|
||||
|
||||
func matchBsonD(filter bson.D, tok *model.VerificationToken) bool {
|
||||
for _, elem := range filter {
|
||||
if !matchElem(elem, tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchElem(elem bson.E, tok *model.VerificationToken) bool {
|
||||
switch elem.Key {
|
||||
|
||||
case "$and":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range arr {
|
||||
d, ok := sub.(bson.D)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !matchBsonD(d, tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
// Either a direct field match or a comparison operator doc.
|
||||
stored := tokenFieldValue(tok, elem.Key)
|
||||
|
||||
// Check for operator document like {$gt: value}
|
||||
if opDoc, ok := elem.Value.(bson.M); ok {
|
||||
return matchOperator(stored, opDoc)
|
||||
}
|
||||
|
||||
// Direct equality (including nil check).
|
||||
return valuesEqual(stored, elem.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func matchOperator(stored any, ops bson.M) bool {
|
||||
for op, cmpVal := range ops {
|
||||
switch op {
|
||||
case "$gt":
|
||||
if !timeGt(stored, cmpVal) {
|
||||
return false
|
||||
}
|
||||
case "$lt":
|
||||
if !timeLt(stored, cmpVal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func valuesEqual(a, b any) bool {
|
||||
// nil checks: usedAt == nil
|
||||
if b == nil {
|
||||
return a == nil || a == (*time.Time)(nil)
|
||||
}
|
||||
switch av := a.(type) {
|
||||
case *time.Time:
|
||||
if av == nil {
|
||||
return b == nil
|
||||
}
|
||||
if bv, ok := b.(*time.Time); ok {
|
||||
return av.Equal(*bv)
|
||||
}
|
||||
return false
|
||||
case bson.ObjectID:
|
||||
if bv, ok := b.(bson.ObjectID); ok {
|
||||
return av == bv
|
||||
}
|
||||
return false
|
||||
case model.VerificationPurpose:
|
||||
if bv, ok := b.(model.VerificationPurpose); ok {
|
||||
return av == bv
|
||||
}
|
||||
return false
|
||||
case string:
|
||||
if bv, ok := b.(string); ok {
|
||||
return av == bv
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func timeGt(stored, cmpVal any) bool {
|
||||
st, ok := toTime(stored)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ct, ok := toTime(cmpVal)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return st.After(ct)
|
||||
}
|
||||
|
||||
func timeLt(stored, cmpVal any) bool {
|
||||
st, ok := toTime(stored)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ct, ok := toTime(cmpVal)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return st.Before(ct)
|
||||
}
|
||||
|
||||
func toTime(v any) (time.Time, bool) {
|
||||
switch tv := v.(type) {
|
||||
case time.Time:
|
||||
return tv, true
|
||||
case *time.Time:
|
||||
if tv == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return *tv, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// applyPatch applies $set operations from a patch bson.D to a token.
|
||||
func applyPatch(tok *model.VerificationToken, patchDoc bson.D) {
|
||||
for _, op := range patchDoc {
|
||||
if op.Key != "$set" {
|
||||
continue
|
||||
}
|
||||
fields, ok := op.Value.(bson.D)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range fields {
|
||||
switch f.Key {
|
||||
case "usedAt":
|
||||
if t, ok := f.Value.(time.Time); ok {
|
||||
tok.UsedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneToken(src *model.VerificationToken) *model.VerificationToken {
|
||||
dst := *src
|
||||
if src.UsedAt != nil {
|
||||
t := *src.UsedAt
|
||||
dst.UsedAt = &t
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
|
||||
// allTokens returns every stored token for inspection in tests.
|
||||
func (m *memoryTokenRepository) allTokens() []*model.VerificationToken {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*model.VerificationToken, 0, len(m.data))
|
||||
for _, id := range m.order {
|
||||
if tok, ok := m.data[id]; ok {
|
||||
out = append(out, cloneToken(tok))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreate_ReturnsRawToken(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
raw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, raw)
|
||||
}
|
||||
|
||||
func TestCreate_TokenCanBeConsumed(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
raw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, raw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
assert.Equal(t, model.PurposePasswordReset, tok.Purpose)
|
||||
assert.NotNil(t, tok.UsedAt)
|
||||
}
|
||||
|
||||
func TestConsume_ReturnsCorrectFields(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
raw, err := db.Create(ctx, accountRef, model.PurposeEmailChange, "new@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, raw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
assert.Equal(t, model.PurposeEmailChange, tok.Purpose)
|
||||
assert.Equal(t, "new@example.com", tok.Target)
|
||||
}
|
||||
|
||||
func TestConsume_SecondConsumeFailsAlreadyUsed(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
raw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Consume(ctx, raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Consume(ctx, raw)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenAlreadyUsed),
|
||||
"second consume should fail because usedAt is set")
|
||||
}
|
||||
|
||||
func TestConsume_ExpiredTokenFails(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
// Create with a TTL that is already in the past.
|
||||
raw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", -time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Consume(ctx, raw)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenExpired),
|
||||
"expired token should not be consumable")
|
||||
}
|
||||
|
||||
func TestConsume_UnknownTokenFails(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := db.Consume(ctx, "nonexistent-token-value")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenNotFound))
|
||||
}
|
||||
|
||||
func TestCreate_InvalidatesPreviousToken(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
oldRaw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
newRaw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, oldRaw, newRaw, "new token should differ from old one")
|
||||
|
||||
// Old token is no longer consumable.
|
||||
_, err = db.Consume(ctx, oldRaw)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenAlreadyUsed),
|
||||
"old token should be invalidated (usedAt set) after new token creation")
|
||||
|
||||
// New token works fine.
|
||||
tok, err := db.Consume(ctx, newRaw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
}
|
||||
|
||||
func TestCreate_InvalidatesMultiplePreviousTokens(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
first, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
second, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
third, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Consume(ctx, first)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenAlreadyUsed), "first should be invalidated")
|
||||
_, err = db.Consume(ctx, second)
|
||||
assert.True(t, errors.Is(err, verification.ErrTokenAlreadyUsed), "second should be invalidated")
|
||||
|
||||
tok, err := db.Consume(ctx, third)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
}
|
||||
|
||||
func TestCreate_DifferentPurposeNotInvalidated(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
resetRaw, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Creating an activation token should NOT invalidate the password-reset token.
|
||||
_, err = db.Create(ctx, accountRef, model.PurposeAccountActivation, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, resetRaw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.PurposePasswordReset, tok.Purpose)
|
||||
}
|
||||
|
||||
func TestCreate_DifferentTargetNotInvalidated(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
firstRaw, err := db.Create(ctx, accountRef, model.PurposeEmailChange, "a@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Creating a token for a different target email should NOT invalidate the first.
|
||||
_, err = db.Create(ctx, accountRef, model.PurposeEmailChange, "b@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, firstRaw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "a@example.com", tok.Target)
|
||||
}
|
||||
|
||||
func TestCreate_DifferentAccountNotInvalidated(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
account1 := bson.NewObjectID()
|
||||
account2 := bson.NewObjectID()
|
||||
|
||||
raw1, err := db.Create(ctx, account1, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Create(ctx, account2, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, raw1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, account1, tok.AccountRef)
|
||||
}
|
||||
|
||||
func TestCreate_AlreadyUsedTokenNotInvalidatedAgain(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
// Create and consume first token.
|
||||
raw1, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Consume(ctx, raw1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create second — the already-consumed token should have usedAt set,
|
||||
// so the invalidation query (usedAt == nil) should skip it.
|
||||
// This tests that the PatchMany filter correctly excludes already-used tokens.
|
||||
raw2, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, raw2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
}
|
||||
|
||||
func TestCreate_ExpiredTokenNotInvalidated(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
|
||||
// Create a token that is already expired.
|
||||
_, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", -time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fresh one — invalidation should skip the expired token (expiresAt > now filter).
|
||||
raw2, err := db.Create(ctx, accountRef, model.PurposePasswordReset, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, err := db.Consume(ctx, raw2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accountRef, tok.AccountRef)
|
||||
}
|
||||
|
||||
func TestTokenHash_Deterministic(t *testing.T) {
|
||||
h1 := tokenHash("same-input")
|
||||
h2 := tokenHash("same-input")
|
||||
assert.Equal(t, h1, h2)
|
||||
}
|
||||
|
||||
func TestTokenHash_DifferentInputs(t *testing.T) {
|
||||
h1 := tokenHash("input-a")
|
||||
h2 := tokenHash("input-b")
|
||||
assert.NotEqual(t, h1, h2)
|
||||
}
|
||||
28
api/pkg/db/verification/errors.go
Normal file
28
api/pkg/db/verification/errors.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("vtNotFound")
|
||||
ErrTokenAlreadyUsed = errors.New("vtAlreadyUsed")
|
||||
ErrTokenExpired = errors.New("vtExpired")
|
||||
)
|
||||
|
||||
func wrap(err error, msg string) error {
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
func ErorrTokenNotFound() error {
|
||||
return wrap(ErrTokenNotFound, "verification token not found")
|
||||
}
|
||||
|
||||
func ErorrTokenAlreadyUsed() error {
|
||||
return wrap(ErrTokenAlreadyUsed, "verification token has already been used")
|
||||
}
|
||||
|
||||
func ErorrTokenExpired() error {
|
||||
return wrap(ErrTokenExpired, "verification token expired")
|
||||
}
|
||||
@@ -2,19 +2,12 @@ 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(
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.24.0
|
||||
require (
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-colorable v0.1.14
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
@@ -45,7 +45,7 @@ require (
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -89,7 +89,7 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
|
||||
@@ -43,8 +43,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -70,8 +70,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rH
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -242,8 +242,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
|
||||
@@ -32,7 +32,7 @@ func InvalidArgument(msg string, argumentNames ...string) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidArg, invalidArgumentMessage(msg, argumentNames...))
|
||||
}
|
||||
|
||||
var ErrDataConflict = errors.New("DataConflict")
|
||||
var ErrDataConflict = errors.New("dataConflict")
|
||||
|
||||
func DataConflict(msg string) error {
|
||||
return fmt.Errorf("%w: %s", ErrDataConflict, msg)
|
||||
|
||||
@@ -12,12 +12,14 @@ import (
|
||||
|
||||
type AccountNotification struct {
|
||||
messaging.Envelope
|
||||
accountRef bson.ObjectID
|
||||
accountRef bson.ObjectID
|
||||
verificationToken string
|
||||
}
|
||||
|
||||
func (acn *AccountNotification) Serialize() ([]byte, error) {
|
||||
var msg gmessaging.AccountCreatedEvent
|
||||
msg.AccountRef = acn.accountRef.Hex()
|
||||
msg.VerificationToken = acn.verificationToken
|
||||
data, err := proto.Marshal(&msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -29,9 +31,10 @@ func NewAccountNotification(action nm.NotificationAction) model.NotificationEven
|
||||
return model.NewNotification(mservice.Accounts, action)
|
||||
}
|
||||
|
||||
func NewAccountImp(sender string, accountRef bson.ObjectID, action nm.NotificationAction) messaging.Envelope {
|
||||
func NewAccountImp(sender string, accountRef bson.ObjectID, action nm.NotificationAction, verificationToken string) messaging.Envelope {
|
||||
return &AccountNotification{
|
||||
Envelope: messaging.CreateEnvelope(sender, NewAccountNotification(action)),
|
||||
accountRef: accountRef,
|
||||
Envelope: messaging.CreateEnvelope(sender, NewAccountNotification(action)),
|
||||
accountRef: accountRef,
|
||||
verificationToken: verificationToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,13 @@ func (acnp *AccoountNotificaionProcessor) Process(ctx context.Context, envelope
|
||||
acnp.logger.Warn("Failed to restore object ID", zap.Error(err), zap.String("topic", acnp.event.ToString()), zap.String("account_ref", msg.AccountRef))
|
||||
return err
|
||||
}
|
||||
verificationToken := msg.GetVerificationToken()
|
||||
var account model.Account
|
||||
if err := acnp.db.Get(ctx, accountRef, &account); err != nil {
|
||||
acnp.logger.Warn("Failed to fetch account", zap.Error(err), zap.String("topic", acnp.event.ToString()), zap.String("account_ref", msg.AccountRef))
|
||||
return err
|
||||
}
|
||||
return acnp.handler(ctx, &account)
|
||||
return acnp.handler(ctx, &account, verificationToken)
|
||||
}
|
||||
|
||||
func (acnp *AccoountNotificaionProcessor) GetSubject() model.NotificationEvent {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
gmessaging "github.com/tech/sendico/pkg/generated/gmessaging"
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
@@ -16,10 +16,10 @@ type NResultNotification struct {
|
||||
|
||||
func (nrn *NResultNotification) Serialize() ([]byte, error) {
|
||||
msg := gmessaging.NotificationSentEvent{
|
||||
UserID: nrn.result.UserID,
|
||||
UserId: nrn.result.UserID,
|
||||
Channel: nrn.result.Channel,
|
||||
Locale: nrn.result.Locale,
|
||||
TemplateID: nrn.result.TemplateID,
|
||||
TemplateId: nrn.result.TemplateID,
|
||||
Status: &gmessaging.OperationResult{
|
||||
IsSuccessful: nrn.result.Result.IsSuccessful,
|
||||
ErrorDescription: nrn.result.Result.Error,
|
||||
|
||||
@@ -3,8 +3,8 @@ package notifications
|
||||
import (
|
||||
"context"
|
||||
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
gmessaging "github.com/tech/sendico/pkg/generated/gmessaging"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
nh "github.com/tech/sendico/pkg/messaging/notifications/notification/handler"
|
||||
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -27,10 +27,10 @@ func (nrp *NResultNotificaionProcessor) Process(ctx context.Context, envelope me
|
||||
}
|
||||
nresult := &model.NotificationResult{
|
||||
AmpliEvent: model.AmpliEvent{
|
||||
UserID: msg.UserID,
|
||||
UserID: msg.UserId,
|
||||
},
|
||||
Channel: msg.Channel,
|
||||
TemplateID: msg.TemplateID,
|
||||
TemplateID: msg.TemplateId,
|
||||
Locale: msg.Locale,
|
||||
Result: model.OperationResult{
|
||||
IsSuccessful: msg.Status.IsSuccessful,
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func Account(sender string, accountRef bson.ObjectID, action nm.NotificationAction) messaging.Envelope {
|
||||
return an.NewAccountImp(sender, accountRef, action)
|
||||
func Account(sender string, accountRef bson.ObjectID, action nm.NotificationAction, verificationToken string) messaging.Envelope {
|
||||
return an.NewAccountImp(sender, accountRef, action, verificationToken)
|
||||
}
|
||||
|
||||
func AccountCreated(sender string, accountRef bson.ObjectID) messaging.Envelope {
|
||||
return Account(sender, accountRef, nm.NACreated)
|
||||
func AccountCreated(sender string, accountRef bson.ObjectID, verificationToken string) messaging.Envelope {
|
||||
return Account(sender, accountRef, nm.NACreated, verificationToken)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type AccountHandler = func(context.Context, *model.Account) error
|
||||
type AccountHandler = func(context.Context, *model.Account, string) error
|
||||
|
||||
type PasswordResetHandler = func(context.Context, *model.Account, string) error
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -11,6 +9,14 @@ import (
|
||||
|
||||
type Filter int
|
||||
|
||||
type AccountStatus string
|
||||
|
||||
const (
|
||||
AccountPendingVerification AccountStatus = "pending_verification"
|
||||
AccountActive AccountStatus = "active"
|
||||
AccountBlocked AccountStatus = "blocked"
|
||||
)
|
||||
|
||||
type AccountBase struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
ArchivableBase `bson:",inline" json:",inline"`
|
||||
@@ -29,10 +35,21 @@ type AccountPublic struct {
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
AccountPublic `bson:",inline" json:",inline"`
|
||||
EmailBackup string `bson:"emailBackup" json:"-"`
|
||||
Password string `bson:"password" json:"-"` // password hash
|
||||
EmailVerifiedAt *time.Time `bson:"emailVerifiedAt,omitempty" json:"-"`
|
||||
AccountPublic `bson:",inline" json:",inline"`
|
||||
Password string `bson:"password" json:"-"` // password hash
|
||||
Status AccountStatus `bson:"status" json:"-"`
|
||||
}
|
||||
|
||||
func (a *Account) Copy() *Account {
|
||||
return &Account{
|
||||
AccountPublic: a.AccountPublic,
|
||||
Password: a.Password,
|
||||
Status: a.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) IsActive() bool {
|
||||
return a.Status == AccountActive
|
||||
}
|
||||
|
||||
func (a *Account) HashPassword() error {
|
||||
|
||||
Reference in New Issue
Block a user