Separate second-factor verification and delivery from auth (#40)
This commit is contained in:
1 parent
12a38cd654
commit
ecb63b61c6
33 files changed
+1772
-123
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user