Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

+17 -50
View File
@@ -1,4 +1,4 @@
// Package flow owns signup and email challenge workflows.
// Package flow owns signup and account activation, independently of second-factor checks.
package flow
import (
@@ -7,7 +7,6 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/mail"
"net/url"
"strings"
"time"
@@ -26,7 +25,6 @@ import (
)
const Cooldown = time.Minute
const LoginTTL = 10 * time.Minute
type Service struct {
Accounts account.DB
@@ -49,14 +47,6 @@ func (s *Service) Validate() error {
}
return nil
}
func NormalizeEmail(email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
a, err := mail.ParseAddress(email)
if err != nil || a.Address != email || len(email) > 254 {
return "", merrors.InvalidArgument("valid email required")
}
return email, nil
}
func (s *Service) key(parts ...string) string {
h := hmac.New(sha256.New, s.Secret)
for _, p := range parts {
@@ -83,7 +73,7 @@ func (s *Service) Limit(ctx context.Context, ip, email string) error {
return nil
}
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
email, err := NormalizeEmail(a.Login)
email, err := customer.NormalizeEmail(a.Login)
if err != nil {
return nil, err
}
@@ -120,12 +110,12 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
}
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
} else {
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
_, err = s.sendActivation(ctx, a, key)
}
return a, err
}
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
email, err := NormalizeEmail(email)
email, err := customer.NormalizeEmail(email)
if err != nil {
return err
}
@@ -142,56 +132,33 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
_, err = s.sendActivation(ctx, a, key)
return err
}
func (s *Service) Issue(ctx context.Context, a *customer.Account, purpose verificationmodel.VerificationPurpose, scope, key string, deadlines ...time.Time) (time.Time, error) {
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required (maximum 128 characters)")
return time.Time{}, merrors.InvalidArgument("idempotency key required")
}
secretKey := s.key(a.ID.Hex(), string(purpose), scope, key)
req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5)
if purpose == verificationmodel.PurposeAccountActivation {
req = verification.NewLinkRequest(a.ID, purpose, "").WithTTL(24 * time.Hour)
}
if len(deadlines) > 0 {
remaining := time.Until(deadlines[0])
if remaining <= 0 {
return time.Time{}, verification.ErorrTokenExpired()
}
if remaining < req.Ttl {
req.WithTTL(remaining)
}
}
req.WithContextRef(scope).WithIdempotencyKey(secretKey).WithCooldown(Cooldown)
now := time.Now().UTC()
// Destination limits span every challenge/context, including password retries.
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, now))
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req)
if err != nil {
return time.Time{}, err
}
if !result.ExpiresAt.After(now) {
if !result.ExpiresAt.After(time.Now()) {
return time.Time{}, verification.ErorrTokenExpired()
}
locale := "en"
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
locale = "ru"
}
message := notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.login", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"code": result.VerificationToken}}
if purpose == verificationmodel.PurposeAccountActivation {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
q.Set("token", result.VerificationToken)
u.RawQuery = q.Encode()
message.Template = "auth.activation"
message.Parameters = map[string]string{"url": u.String()}
}
// Replay recovers ambiguous RPC failures using the same rendered payload and expiry.
if err = s.Sender.Send(ctx, message); err != nil {
return time.Time{}, err
}
return result.ExpiresAt, nil
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
q.Set("token", result.VerificationToken)
u.RawQuery = q.Encode()
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
return result.ExpiresAt, err
}
func (s *Service) Activate(ctx context.Context, raw string) error {
if len(raw) != 43 {
+7 -8
View File
@@ -4,7 +4,6 @@ import (
"context"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/notifications"
model "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
@@ -39,26 +38,26 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
expiry := time.Now().UTC().Add(time.Minute)
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32))}
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Login = "user@example.com"
a.Locale = "ru-RU"
_, err := service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
_, err := service.sendActivation(context.Background(), a, "client-key")
require.Error(t, err)
key := *tokens.request.IdempotencyKey
require.NotEqual(t, "client-key", key)
require.Equal(t, "pending-scope", tokens.request.ContextRef)
require.LessOrEqual(t, tokens.request.Ttl, time.Minute)
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
sender.err = nil
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
_, err = service.sendActivation(context.Background(), a, "client-key")
require.NoError(t, err)
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.key(a.ID.Hex(), string(model.PurposeLogin), "pending-scope", "client-key"))
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "expired")
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
require.Len(t, sender.messages, 2)
}