Integrate email notifications into signup and login (#38)
This commit is contained in:
1 parent
326d58f705
commit
ad5a061164
55 files changed
+1859
-3464
No files matched your search
@@ -1,24 +1,13 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const hashPreviewChars = 12
|
||||
|
||||
func hashPreview(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= hashPreviewChars {
|
||||
return value
|
||||
}
|
||||
return value[:hashPreviewChars] + "..."
|
||||
}
|
||||
|
||||
func tokenIsDigitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
@@ -38,7 +27,6 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.String("token_purpose", string(token.Purpose)),
|
||||
zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""),
|
||||
zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil),
|
||||
zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.Bool("token_has_salt", token.Salt != nil),
|
||||
zap.Bool("token_used", token.UsedAt != nil),
|
||||
zap.Time("token_expires_at", token.ExpiresAt),
|
||||
@@ -46,18 +34,12 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.Bool("token_has_max_retries", token.MaxRetries != nil),
|
||||
}
|
||||
|
||||
if token.Salt != nil {
|
||||
fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt)))
|
||||
}
|
||||
if token.UsedAt != nil {
|
||||
fields = append(fields, zap.Time("token_used_at", *token.UsedAt))
|
||||
}
|
||||
if token.MaxRetries != nil {
|
||||
fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries))
|
||||
}
|
||||
if token.IdempotencyKey != nil {
|
||||
fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey)))
|
||||
}
|
||||
if strings.TrimSpace(token.ContextRef) != "" {
|
||||
fields = append(fields, zap.String("token_context_ref", token.ContextRef))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
@@ -132,8 +132,6 @@ func (db *verificationDB) lookupConsumeOTPToken(
|
||||
zap.Time("candidate_expires_at", token.ExpiresAt),
|
||||
zap.Int("candidate_attempts", token.Attempts),
|
||||
zap.Bool("candidate_has_max_retries", token.MaxRetries != nil),
|
||||
zap.String("candidate_stored_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.String("candidate_computed_hash_prefix", hashPreview(hash)),
|
||||
zap.Bool("candidate_match", match),
|
||||
)
|
||||
if match {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -72,7 +72,6 @@ func (scope consumeScope) startFields() []zap.Field {
|
||||
zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)),
|
||||
zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)),
|
||||
zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken),
|
||||
zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -21,7 +21,7 @@ func activeContextFilter(request *Request, now time.Time) builder.Query {
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func cooldownActiveContextFilter(request *Request, now, cutoff time.Time) builde
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
|
||||
@@ -140,7 +140,10 @@ func (db *verificationDB) CreateWithResult(
|
||||
// Always store an idempotency key. Client keys enable replay; generated
|
||||
// keys avoid null-key collisions on older Mongo indexes and keep
|
||||
// transaction retries deterministic.
|
||||
idempotencyKey = syntheticIdempotencyKey()
|
||||
idempotencyKey, err = syntheticIdempotencyKey()
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
hasIdempotency = true
|
||||
}
|
||||
|
||||
@@ -246,3 +249,12 @@ func (db *verificationDB) CreateWithResult(
|
||||
createResult.VerificationToken = raw
|
||||
return createResult, nil
|
||||
}
|
||||
|
||||
// Older records omit an empty target; Mongo does not equate a missing field
|
||||
// with the empty string. Match both forms for cooldown, invalidation and replay.
|
||||
func targetFilter(target string) builder.Query {
|
||||
if target != "" {
|
||||
return repository.Filter("target", target)
|
||||
}
|
||||
return repository.Query().Or(repository.Filter("target", ""), repository.Filter("target", nil))
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
@@ -24,8 +25,12 @@ func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
return key, true
|
||||
}
|
||||
|
||||
func syntheticIdempotencyKey() string {
|
||||
return "auto:" + bson.NewObjectID().Hex()
|
||||
func syntheticIdempotencyKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", merrors.InternalWrap(err, "generate verification key")
|
||||
}
|
||||
return "auto:" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func idempotencyContextFilter(
|
||||
@@ -35,7 +40,7 @@ func idempotencyContextFilter(
|
||||
return repository.Query().And(
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("idempotencyKey", idempotencyKey),
|
||||
)
|
||||
}
|
||||
@@ -75,6 +80,12 @@ func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *
|
||||
if existingContextRef != request.ContextRef {
|
||||
return nil, false, ErrorIdempotencyConflict()
|
||||
}
|
||||
if tokens[i].UsedAt != nil {
|
||||
return nil, false, ErorrTokenAlreadyUsed()
|
||||
}
|
||||
if tokens[i].MaxRetries != nil && tokens[i].Attempts >= *tokens[i].MaxRetries {
|
||||
return nil, false, ErrorTokenAttemptsExceeded()
|
||||
}
|
||||
if replayed == nil {
|
||||
token := tokens[i]
|
||||
replayed = &token
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -62,6 +62,18 @@ func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool {
|
||||
|
||||
func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool {
|
||||
switch elem.Key {
|
||||
case "$or":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range arr {
|
||||
d, ok := sub.(bson.D)
|
||||
if ok && matchBsonD(d, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "$and":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
|
||||
@@ -3,12 +3,13 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreate_CooldownBlocksCreation(t *testing.T) {
|
||||
@@ -174,3 +175,16 @@ func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) {
|
||||
}
|
||||
repo.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestCreateWithResultReplayCannotRedeliverConsumedCode(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
id := bson.NewObjectID()
|
||||
r := NewOTPRequest(id, verificationmodel.PurposeLogin, "").WithIdempotencyKey("private-server-key").WithContextRef("login")
|
||||
first, err := db.Create(ctx, r)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Consume(ctx, id, verificationmodel.PurposeLogin, "login", first)
|
||||
require.NoError(t, err)
|
||||
_, err = db.CreateWithResult(ctx, r)
|
||||
require.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
Reference in new issue
Block a user