From ecb63b61c61fe0e086288ec8b3bdbb81f1ae39ae Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 20:36:35 +0200 Subject: [PATCH] Separate second-factor verification and delivery from auth (#40) --- backend/PROVENANCE.md | 8 + backend/notifications/templates.example.json | 20 ++ backend/product/app/http/middleware.go | 26 +- .../product/architecture/twofactor_test.go | 48 ++++ backend/product/auth/flow/service.go | 67 ++--- backend/product/auth/flow/service_test.go | 15 +- backend/product/auth/http/dispatcher.go | 7 +- backend/product/auth/http/public/login.go | 2 +- .../product/auth/http/public/login_test.go | 1 - .../product/auth/http/public/refresh_test.go | 1 - backend/product/auth/http/public/respond.go | 14 +- backend/product/auth/http/public/router.go | 20 +- .../product/auth/http/public/verification.go | 55 ++-- .../public/verification_integration_test.go | 17 +- backend/product/customers/model/email.go | 16 ++ backend/product/twofactor/challenge.go | 138 ++++++++++ backend/product/twofactor/delivery/email.go | 23 ++ backend/product/twofactor/http/router.go | 188 ++++++++++++++ backend/product/twofactor/http/router_test.go | 45 ++++ backend/product/twofactor/model/model.go | 83 +++++++ backend/product/twofactor/preferences.go | 156 ++++++++++++ backend/product/twofactor/proof.go | 153 ++++++++++++ backend/product/twofactor/service.go | 159 ++++++++++++ backend/product/twofactor/service_test.go | 235 ++++++++++++++++++ backend/product/twofactor/store/mongo.go | 53 ++++ .../product/verification/model/verificaton.go | 9 + docs/architecture/two-factor.md | 155 ++++++++++++ docs/auth-notifications.md | 22 +- docs/implementation.md | 44 ++++ .../sxchnge_auth/lib/src/dto/backend.dart | 2 + .../sxchnge_auth/lib/src/mappers/backend.dart | 23 +- .../lib/src/services/backend.dart | 16 +- .../sxchnge_auth/test/backend_test.dart | 74 ++++++ 33 files changed, 1772 insertions(+), 123 deletions(-) create mode 100644 backend/product/architecture/twofactor_test.go create mode 100644 backend/product/customers/model/email.go create mode 100644 backend/product/twofactor/challenge.go create mode 100644 backend/product/twofactor/delivery/email.go create mode 100644 backend/product/twofactor/http/router.go create mode 100644 backend/product/twofactor/http/router_test.go create mode 100644 backend/product/twofactor/model/model.go create mode 100644 backend/product/twofactor/preferences.go create mode 100644 backend/product/twofactor/proof.go create mode 100644 backend/product/twofactor/service.go create mode 100644 backend/product/twofactor/service_test.go create mode 100644 backend/product/twofactor/store/mongo.go create mode 100644 docs/architecture/two-factor.md diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md index b7a71c6..c5b8c32 100644 --- a/backend/PROVENANCE.md +++ b/backend/PROVENANCE.md @@ -71,3 +71,11 @@ code. Auth and verification configurations moved out of application interfaces. All 112 unconsumed public API snapshot files and their snapshot-only check were removed. Future public adapters will own just the wire types they consume. Backoffice is a separate future executable module, not an alias for product. + +## Second-factor separation (#40) + +The new `product/twofactor` domain service reuses the already copied +`product/verification/store` issuance and consumption implementation at the pinned +Sendico revision. No additional Sendico source files were copied. Coupled +verification HTTP/session/private-messaging handlers were inspected but excluded. +See [reuse decisions and domain boundary](../docs/architecture/two-factor.md). diff --git a/backend/notifications/templates.example.json b/backend/notifications/templates.example.json index d89fa82..d0041dc 100644 --- a/backend/notifications/templates.example.json +++ b/backend/notifications/templates.example.json @@ -55,5 +55,25 @@ "html": "

Ваш код входа: {{.code}}.

Он действует 10 минут. Никому не сообщайте код.

" } } + }, + { + "id": "twofactor.code", + "version": "1", + "default_locale": "en", + "parameters": [ + "code" + ], + "locales": { + "en": { + "subject": "Your SXCHNGE verification code", + "text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.", + "html": "

Your verification code is {{.code}}.

It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.

" + }, + "ru": { + "subject": "Код подтверждения SXCHNGE", + "text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.", + "html": "

Ваш код подтверждения: {{.code}}.

Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.

" + } + } } ] diff --git a/backend/product/app/http/middleware.go b/backend/product/app/http/middleware.go index 61854a9..207916e 100644 --- a/backend/product/app/http/middleware.go +++ b/backend/product/app/http/middleware.go @@ -9,11 +9,16 @@ import ( "git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/flow" httprouter "git.sendico.io/tech/sxchnge/product/auth/http" + public "git.sendico.io/tech/sxchnge/product/auth/http/public" "git.sendico.io/tech/sxchnge/product/auth/response" authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" accountservice "git.sendico.io/tech/sxchnge/product/customers" "git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/product/twofactor" + delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery" + factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http" + factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" "git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/errors" api "git.sendico.io/tech/sxchnge/shared/http" @@ -148,6 +153,7 @@ func CreateMiddleware( } var authFlow *flow.Service + var factors *twofactor.Service if len(notificationConfig) > 0 && notificationConfig[0] != nil { c := notificationConfig[0] customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage) @@ -163,17 +169,33 @@ func CreateMiddleware( return nil, err } authFlow.Sender = p.notifications + records, err := factorstore.Open(p.logger, db) + if err != nil { + _ = p.notifications.Close() + return nil, err + } + factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications}) + if err != nil { + _ = p.notifications.Close() + return nil, err + } + } + flows := public.Flows{Onboarding: authFlow} + if factors != nil { + flows.Factors = factors } p.epdispatcher = httprouter.NewDispatcher( p.logger, p.router, stores.Accounts, - stores.Verification, stores.RefreshTokens, stores.LoginAttempts, config, loginRateLimits, - authFlow, + flows, ) + if factors != nil { + factorhttp.Install(p.epdispatcher, factors, p.logger) + } return p, nil } diff --git a/backend/product/architecture/twofactor_test.go b/backend/product/architecture/twofactor_test.go new file mode 100644 index 0000000..77ed07c --- /dev/null +++ b/backend/product/architecture/twofactor_test.go @@ -0,0 +1,48 @@ +package architecture + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Keep the reusable domain independent of transport and session ownership. +func TestSecondFactorDomainBoundary(t *testing.T) { + err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "http" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, im := range file.Imports { + name, err := strconv.Unquote(im.Path.Value) + if err != nil { + return err + } + for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} { + if strings.Contains(name, blocked) { + t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/product/auth/flow/service.go b/backend/product/auth/flow/service.go index 703b3fa..4679b4c 100644 --- a/backend/product/auth/flow/service.go +++ b/backend/product/auth/flow/service.go @@ -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 { diff --git a/backend/product/auth/flow/service_test.go b/backend/product/auth/flow/service_test.go index 42ff9f0..8da6031 100644 --- a/backend/product/auth/flow/service_test.go +++ b/backend/product/auth/flow/service_test.go @@ -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) } diff --git a/backend/product/auth/http/dispatcher.go b/backend/product/auth/http/dispatcher.go index 9009954..01e383b 100644 --- a/backend/product/auth/http/dispatcher.go +++ b/backend/product/auth/http/dispatcher.go @@ -4,13 +4,11 @@ import ( "os" "git.sendico.io/tech/sxchnge/product/auth/config" - "git.sendico.io/tech/sxchnge/product/auth/flow" rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" "git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/sessions/store" - "git.sendico.io/tech/sxchnge/product/verification/store" api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/ratelimit/store" @@ -40,12 +38,11 @@ func NewDispatcher( logger mlogger.Logger, router chi.Router, db account.DB, - vdb verification.DB, rtdb refreshtokens.DB, attempts ratelimits.Store, config *middleware.Config, loginRateLimits middleware.LoginRateLimits, - flows ...*flow.Service, + flows ...rpublic.Flows, ) *Dispatcher { d := &Dispatcher{ logger: logger.Named("api_dispatcher"), @@ -55,7 +52,7 @@ func NewDispatcher( endpoint := os.Getenv(config.EndPointEnv) signature := middleware.SignatureConf(config) router.Group(func(r chi.Router) { - d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...) + d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...) }) router.Group(func(r chi.Router) { d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature) diff --git a/backend/product/auth/http/public/login.go b/backend/product/auth/http/public/login.go index 4bc9999..7f25dce 100644 --- a/backend/product/auth/http/public/login.go +++ b/backend/product/auth/http/public/login.go @@ -225,7 +225,7 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc { if h = a.validateClientAccountBinding(client, account); h != nil { return h } - if a.flow != nil { + if a.factors != nil { return a.beginLogin(r, account, req.SessionIdentifier, randomKey()) } return a.respondPendingLogin(account) diff --git a/backend/product/auth/http/public/login_test.go b/backend/product/auth/http/public/login_test.go index c73d490..1a589d8 100644 --- a/backend/product/auth/http/public/login_test.go +++ b/backend/product/auth/http/public/login_test.go @@ -187,7 +187,6 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { zap.NewNop(), "/", &testAccountDB{result: account}, - nil, rtdb, nil, chi.NewRouter(), diff --git a/backend/product/auth/http/public/refresh_test.go b/backend/product/auth/http/public/refresh_test.go index 7a8810a..d74b3c4 100644 --- a/backend/product/auth/http/public/refresh_test.go +++ b/backend/product/auth/http/public/refresh_test.go @@ -51,7 +51,6 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, zap.NewNop(), "/api/v1", &testAccountDB{result: account}, - nil, rtdb, nil, chi.NewRouter(), diff --git a/backend/product/auth/http/public/respond.go b/backend/product/auth/http/public/respond.go index 17a669a..e9d96e8 100644 --- a/backend/product/auth/http/public/respond.go +++ b/backend/product/auth/http/public/respond.go @@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin( session *sessionmodel.SessionIdentifier, account *customermodel.Account, ) http.HandlerFunc { + result, err := pr.prepareLogin(ctx, r, session, account) + if err != nil { + return response.Internal(pr.logger, pr.service, err) + } + return result +} + +func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) { refreshToken, err := rtokens.PrepareRefreshToken( ctx, r, @@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin( if err != nil { pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account), zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) - return response.Internal(pr.logger, pr.service, err) + return nil, err } accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier) if err != nil { pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account), zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) - return response.Internal(pr.logger, pr.service, err) + return nil, err } token := sresponse.TokenData{ Token: refreshToken.RefreshToken, Expiration: refreshToken.ExpiresAt, } - return sresponse.Login(pr.logger, account, &accessToken, &token) + return sresponse.Login(pr.logger, account, &accessToken, &token), nil } diff --git a/backend/product/auth/http/public/router.go b/backend/product/auth/http/public/router.go index ad11dbf..47e60b2 100644 --- a/backend/product/auth/http/public/router.go +++ b/backend/product/auth/http/public/router.go @@ -9,7 +9,7 @@ import ( "git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/sessions/store" - "git.sendico.io/tech/sxchnge/product/verification/store" + "git.sendico.io/tech/sxchnge/product/twofactor" api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/ratelimit/store" @@ -17,7 +17,13 @@ import ( "github.com/go-chi/chi/v5" ) +type Flows struct { + Onboarding *flow.Service + Factors twofactor.API +} + type PublicRouter struct { + factors twofactor.API flow *flow.Service logger mlogger.Logger db account.DB @@ -39,14 +45,13 @@ func NewRouter( logger mlogger.Logger, apiEndpoint string, db account.DB, - vdb verification.DB, rtdb refreshtokens.DB, attempts ratelimits.Store, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature, limits middleware.LoginRateLimits, - flows ...*flow.Service, + flows ...Flows, ) *PublicRouter { l := logger.Named("public") hr := PublicRouter{ @@ -62,11 +67,16 @@ func NewRouter( now: time.Now, } - if len(flows) > 0 && flows[0] != nil { - hr.flow = flows[0] + if len(flows) > 0 { + hr.flow = flows[0].Onboarding + hr.factors = flows[0].Factors + } + if hr.flow != nil { hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup)) hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation)) hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate)) + } + if hr.factors != nil { hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin)) hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin)) } diff --git a/backend/product/auth/http/public/verification.go b/backend/product/auth/http/public/verification.go index 115ff60..209f9a9 100644 --- a/backend/product/auth/http/public/verification.go +++ b/backend/product/auth/http/public/verification.go @@ -9,20 +9,18 @@ import ( "errors" "io" "net/http" - "strings" "time" - "git.sendico.io/tech/sxchnge/product/auth/flow" "git.sendico.io/tech/sxchnge/product/auth/ipguard" authmodel "git.sendico.io/tech/sxchnge/product/auth/model" sresponse "git.sendico.io/tech/sxchnge/product/auth/response" customer "git.sendico.io/tech/sxchnge/product/customers/model" session "git.sendico.io/tech/sxchnge/product/sessions/model" + factor "git.sendico.io/tech/sxchnge/product/twofactor/model" verificationmodel "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" "git.sendico.io/tech/sxchnge/shared/http/response" - "git.sendico.io/tech/sxchnge/shared/logging/mask" "github.com/go-chi/jwtauth/v5" "go.uber.org/zap" ) @@ -35,12 +33,15 @@ type signupRequest struct { Key string `json:"idempotencyKey"` } type challengeRequest struct { + ChallengeID string `json:"challengeId"` PendingToken string `json:"pendingToken"` Code string `json:"code"` Key string `json:"idempotencyKey"` Session session.SessionIdentifier `json:"sessionIdentifier"` } type challengeResponse struct { + ChallengeID string `json:"challengeId"` + Channel factor.Channel `json:"channel"` UserID string `json:"userId"` PendingToken sresponse.TokenData `json:"pendingToken"` Target string `json:"target"` @@ -161,19 +162,28 @@ func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, session return pr.flowError(err) } token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)} - return pr.queueLogin(r, a, token, key) + return pr.queueLogin(r, a, token, key, "") } func randomKey() string { return rand.Text() } -func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc { - expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration) +func loginScope(raw string) factor.Scope { + return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)} +} +func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc { + if pr.factors == nil { + return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable")) + } + req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)} + var challenge factor.Challenge + var err error + if id == "" { + challenge, err = pr.factors.Begin(r.Context(), req) + } else { + challenge, err = pr.factors.Resend(r.Context(), req, id) + } if err != nil { return pr.flowError(err) } - if token.Expiration.Before(expires) { - expires = token.Expiration - } - pr.logger.Info("Login verification queued", zap.String("account_ref", a.ID.Hex())) - return response.Accepted(pr.logger, challengeResponse{UserID: a.ID.Hex(), PendingToken: token, Target: mask.Email(a.Login), ExpiresAt: expires, ResendAt: time.Now().Add(flow.Cooldown)}) + return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt}) } func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) { if len(raw) > 8192 { @@ -206,11 +216,14 @@ func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc { if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } + if req.ChallengeID == "" { + return pr.flowError(merrors.InvalidArgument("challenge ID required")) + } a, t, err := pr.pending(r.Context(), req.PendingToken) if err != nil { return pr.flowError(err) } - return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key) + return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID) } func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc { var req challengeRequest @@ -228,13 +241,19 @@ func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc { if h = pr.validateClientAccountBinding(client, a); h != nil { return h } - code := strings.TrimSpace(req.Code) - if len(code) != 6 { - return pr.flowError(verification.ErorrTokenNotFound()) + if pr.factors == nil { + return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable")) } - if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil { + scope := loginScope(req.PendingToken) + var result http.HandlerFunc + err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error { + var err error + result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a) + return err + }) + if err != nil { return pr.flowError(err) } - pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex())) - return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a) + pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex())) + return result } diff --git a/backend/product/auth/http/public/verification_integration_test.go b/backend/product/auth/http/public/verification_integration_test.go index c6d96ee..cfd2e9b 100644 --- a/backend/product/auth/http/public/verification_integration_test.go +++ b/backend/product/auth/http/public/verification_integration_test.go @@ -17,6 +17,9 @@ import ( customer "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/product/notifications" session "git.sendico.io/tech/sxchnge/product/sessions/model" + "git.sendico.io/tech/sxchnge/product/twofactor" + "git.sendico.io/tech/sxchnge/product/twofactor/delivery" + factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" db "git.sendico.io/tech/sxchnge/shared/database" merrors "git.sendico.io/tech/sxchnge/shared/errors" sharedmodel "git.sendico.io/tech/sxchnge/shared/model" @@ -63,8 +66,12 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { require.NoError(t, service.Validate()) // Keep the real verification/customer stores and capture only session writes. sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}} + records, err := factorstore.Open(zap.NewNop(), factory) + require.NoError(t, err) + factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail}) + require.NoError(t, err) routes := chi.NewRouter() - NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, stores.Verification, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, service) + NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors}) post := func(path string, body any) *httptest.ResponseRecorder { raw, err := json.Marshal(body) require.NoError(t, err) @@ -113,14 +120,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { if code == wrong { wrong = "111111" } - require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": wrong}).Code) + require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code) require.Empty(t, sessions.tokens) - require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "idempotencyKey": "resend-too-soon"}).Code) - w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code}) + require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code) + w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}) require.Equal(t, 200, w.Code, w.Body.String()) require.Len(t, sessions.tokens, 1) require.Equal(t, "browser", sessions.tokens[0].DeviceID) - require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code}).Code) + require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code) require.Len(t, sessions.tokens, 1) // A queue outage cannot report a successful login challenge or create a session. mail.fail = true diff --git a/backend/product/customers/model/email.go b/backend/product/customers/model/email.go new file mode 100644 index 0000000..a6db304 --- /dev/null +++ b/backend/product/customers/model/email.go @@ -0,0 +1,16 @@ +package model + +import ( + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "net/mail" + "strings" +) + +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 +} diff --git a/backend/product/twofactor/challenge.go b/backend/product/twofactor/challenge.go new file mode 100644 index 0000000..0a972a0 --- /dev/null +++ b/backend/product/twofactor/challenge.go @@ -0,0 +1,138 @@ +package twofactor + +import ( + "context" + "errors" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + vmodel "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" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "strings" + "time" +) + +func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) { + if !validScope(req.Scope) { + return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope") + } + return s.begin(ctx, req, nil) +} +func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) { + if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) { + return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required") + } + a, p, version, err := s.profile(ctx, req.Subject) + if err != nil { + return model.Challenge{}, err + } + factor, err := s.preferred(p) + if err != nil { + return model.Challenge{}, err + } + if enrollment != nil { + factor = *enrollment + } + // Repeating an operation key resolves to the same immutable challenge snapshot. + id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24]) + expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond) + if !req.Deadline.IsZero() && req.Deadline.Before(expires) { + expires = req.Deadline.UTC().Truncate(time.Millisecond) + } + if !expires.After(time.Now()) { + return model.Challenge{}, verification.ErorrTokenExpired() + } + locale := "en" + if strings.HasPrefix(strings.ToLower(a.Locale), "ru") { + locale = "ru" + } + candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires} + candidate.ID = id + result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + r, err := s.store.Challenge(tx, id) + if errors.Is(err, merrors.ErrNoData) { + r = candidate + if err = s.store.CreateChallenge(tx, r); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version { + return nil, merrors.DataConflict("second-factor request changed") + } + return s.issue(tx, r, req.IdempotencyKey, req.IP) + }) + if err != nil { + return model.Challenge{}, err + } + return s.deliver(ctx, result) +} +func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) { + result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + r, err := s.record(tx, id, req.Subject, req.Scope) + if err != nil { + return nil, err + } + return s.issue(tx, r, req.IdempotencyKey, req.IP) + }) + if err != nil { + return model.Challenge{}, err + } + return s.deliver(ctx, result) +} + +type issued struct { + record *model.Record + delivery model.Delivery + resendAt time.Time +} + +func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) { + if r.VerifiedAt != nil { + return issued{}, verification.ErorrTokenAlreadyUsed() + } + if !r.ExpiresAt.After(time.Now()) { + return issued{}, verification.ErorrTokenExpired() + } + if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) { + return issued{}, merrors.InvalidArgument("idempotency key required") + } + if ip == "" { + ip = "unresolved" + } + secretKey := s.key("code", r.ID.Hex(), key) + now := time.Now().UTC() + req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID). + WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey). + WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now)) + token, err := s.tokens.CreateWithResult(ctx, req) + if err != nil { + return issued{}, err + } + if !token.ExpiresAt.After(now) { + return issued{}, verification.ErorrTokenExpired() + } + return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil +} +func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) { + issued, ok := result.(issued) + if !ok { + return model.Challenge{}, merrors.Internal("invalid second-factor issuance result") + } + driver := s.drivers[issued.record.Factor.Channel] + if driver == nil { + return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable") + } + if err := driver.Send(ctx, issued.delivery); err != nil { + s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel()))) + return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable") + } + s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel()))) + return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil +} + +func preferenceScope(factorID string) model.Scope { + return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID} +} diff --git a/backend/product/twofactor/delivery/email.go b/backend/product/twofactor/delivery/email.go new file mode 100644 index 0000000..0df7868 --- /dev/null +++ b/backend/product/twofactor/delivery/email.go @@ -0,0 +1,23 @@ +package delivery + +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/twofactor/model" + vmodel "git.sendico.io/tech/sxchnge/product/verification/model" + "git.sendico.io/tech/sxchnge/shared/logging/mask" +) + +type Email struct{ Sender notifications.Sender } + +func (Email) Channel() model.Channel { return model.Email } +func (Email) Normalize(address string) (string, error) { return customer.NormalizeEmail(address) } +func (Email) Mask(address string) string { return mask.Email(address) } +func (d Email) Send(ctx context.Context, m model.Delivery) error { + template := "twofactor.code" + if m.Scope.Purpose == vmodel.PurposeLogin { + template = "auth.login" + } + return d.Sender.Send(ctx, notifications.Message{Key: m.Key, UserID: m.Subject.Hex(), Email: m.Factor.Address, Locale: m.Locale, Template: template, Parameters: map[string]string{"code": m.Code}, ExpiresAt: m.ExpiresAt}) +} diff --git a/backend/product/twofactor/http/router.go b/backend/product/twofactor/http/router.go new file mode 100644 index 0000000..375ea3e --- /dev/null +++ b/backend/product/twofactor/http/router.go @@ -0,0 +1,188 @@ +// Package factorhttp exposes authenticated factor settings. Operation scope and +// account ownership are resolved on the server; raw proofs never cross HTTP. +package factorhttp + +import ( + "encoding/json" + "errors" + "io" + "math" + "net/http" + "strconv" + "time" + + "git.sendico.io/tech/sxchnge/product/auth/ipguard" + sresponse "git.sendico.io/tech/sxchnge/product/auth/response" + customer "git.sendico.io/tech/sxchnge/product/customers/model" + "git.sendico.io/tech/sxchnge/product/twofactor" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + verification "git.sendico.io/tech/sxchnge/product/verification/store" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + api "git.sendico.io/tech/sxchnge/shared/http" + "git.sendico.io/tech/sxchnge/shared/http/response" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + mservice "git.sendico.io/tech/sxchnge/shared/service" +) + +type Registrar interface { + AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc) +} +type router struct { + service *twofactor.Service + logger mlogger.Logger +} + +func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) { + h := &router{service: service, logger: logger.Named("twofactor_http")} + reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings) + reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll) + reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment) + reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference) + reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference) + reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend) +} +func decode(r *http.Request, dst any) error { + d := json.NewDecoder(io.LimitReader(r.Body, 8193)) + d.DisallowUnknownFields() + if err := d.Decode(dst); err != nil { + return merrors.InvalidArgument("invalid request") + } + var extra any + if err := d.Decode(&extra); err != io.EOF { + return merrors.InvalidArgument("invalid request") + } + return nil +} +func requestIP(r *http.Request) string { + if ip := ipguard.ClientIP(r); ip != nil { + return ip.String() + } + return "unresolved" +} +func (h *router) result(data any, err error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if err == nil { + response.Accepted(h.logger, data).ServeHTTP(w, r) + return + } + var handler http.HandlerFunc + if throttle, ok := verification.Throttle(err); ok { + w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds()))))) + handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code") + } else { + switch { + case errors.Is(err, verification.ErrTokenExpired): + handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation") + case errors.Is(err, verification.ErrTokenAttemptsExceeded): + handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation") + case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData): + handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used") + case errors.Is(err, merrors.ErrInvalidArg): + handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields") + case errors.Is(err, merrors.ErrUnauthorized): + handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation") + case errors.Is(err, merrors.ErrDataConflict): + handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists") + case errors.Is(err, merrors.ErrNotImplemented): + handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable") + default: + h.logger.Warn("Second-factor settings dependency failed") + handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry") + } + } + handler.ServeHTTP(w, r) + } +} + +type challengeDTO struct { + ID string `json:"challengeId"` + Channel model.Channel `json:"channel"` + Target string `json:"target"` + ExpiresAt time.Time `json:"expiresAt"` + ResendAt time.Time `json:"resendAt"` +} + +func mapChallenge(c model.Challenge) challengeDTO { + return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt} +} + +type choiceDTO struct { + ID string `json:"id"` + Channel model.Channel `json:"channel"` + Target string `json:"target"` +} +type settingsDTO struct { + Required bool `json:"required"` + Preferred string `json:"preferred"` + Factors []choiceDTO `json:"factors"` +} + +func mapSettings(s model.Settings) settingsDTO { + result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}} + for _, f := range s.Factors { + result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target}) + } + return result +} +func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + s, err := h.service.Settings(r.Context(), a.ID) + return h.result(mapSettings(s), err) +} +func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + var req struct { + Channel model.Channel `json:"channel"` + Address string `json:"address"` + Key string `json:"idempotencyKey"` + } + if err := decode(r, &req); err != nil { + return h.result(nil, err) + } + c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r)) + return h.result(mapChallenge(c), err) +} +func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + var req struct { + ID string `json:"challengeId"` + Code string `json:"code"` + } + if err := decode(r, &req); err != nil { + return h.result(nil, err) + } + s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code) + return h.result(mapSettings(s), err) +} +func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + var req struct { + ID string `json:"factorId"` + Key string `json:"idempotencyKey"` + } + if err := decode(r, &req); err != nil { + return h.result(nil, err) + } + c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r)) + return h.result(mapChallenge(c), err) +} +func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + var req struct { + Factor string `json:"factorId"` + ID string `json:"challengeId"` + Code string `json:"code"` + } + if err := decode(r, &req); err != nil { + return h.result(nil, err) + } + s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code) + return h.result(mapSettings(s), err) +} +func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { + var req struct { + ID string `json:"challengeId"` + Key string `json:"idempotencyKey"` + } + if err := decode(r, &req); err != nil { + return h.result(nil, err) + } + c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r)) + return h.result(mapChallenge(c), err) +} diff --git a/backend/product/twofactor/http/router_test.go b/backend/product/twofactor/http/router_test.go new file mode 100644 index 0000000..1045cbf --- /dev/null +++ b/backend/product/twofactor/http/router_test.go @@ -0,0 +1,45 @@ +package factorhttp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + sresponse "git.sendico.io/tech/sxchnge/product/auth/response" + customer "git.sendico.io/tech/sxchnge/product/customers/model" + api "git.sendico.io/tech/sxchnge/shared/http" + mservice "git.sendico.io/tech/sxchnge/shared/service" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type accountRoutes map[string]sresponse.AccountHandlerFunc + +func (r accountRoutes) AccountHandler(_ mservice.Type, path string, _ api.HTTPMethod, h sresponse.AccountHandlerFunc) { + r[path] = h +} + +func TestSettingsTransportRejectsCallerOwnedScopeAndDestinationOverrides(t *testing.T) { + routes := accountRoutes{} + Install(routes, nil, zap.NewNop()) + // Every route is registered through the authenticated-account contract. + require.Len(t, routes, 6) + for _, tc := range []struct{ path, body string }{ + {"/two-factor/enroll", `{"channel":"email","address":"a@example.com","subject":"other-account"}`}, + {"/two-factor/enroll/verify", `{"challengeId":"id","code":"123456","purpose":"login"}`}, + {"/two-factor/preference", `{"factorId":"id","address":"arbitrary@example.com"}`}, + {"/two-factor/preference/verify", `{"factorId":"id","code":"123456","verified":true}`}, + {"/two-factor/resend", `{"challengeId":"id","target":"arbitrary@example.com"}`}, + {"/two-factor/enroll", `{"channel":"email"} {"channel":"sms"}`}, + } { + t.Run(tc.path+tc.body, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + w := httptest.NewRecorder() + routes[tc.path](req, &customer.Account{}, nil).ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + require.NotContains(t, w.Body.String(), "arbitrary@example.com") + }) + } +} diff --git a/backend/product/twofactor/model/model.go b/backend/product/twofactor/model/model.go new file mode 100644 index 0000000..52e77dc --- /dev/null +++ b/backend/product/twofactor/model/model.go @@ -0,0 +1,83 @@ +package model + +import ( + verification "git.sendico.io/tech/sxchnge/product/verification/model" + storable "git.sendico.io/tech/sxchnge/shared/database/record" + "go.mongodb.org/mongo-driver/v2/bson" + "time" +) + +type Channel string + +const Email Channel = "email" + +// Scope is supplied by the owning operation, never accepted as an authorization +// decision from a caller's HTTP body. ContextRef identifies the exact operation. +type Scope struct { + Purpose verification.VerificationPurpose `bson:"purpose"` + ContextRef string `bson:"contextRef"` +} +type Request struct { + Subject bson.ObjectID + Scope Scope + Deadline time.Time + IdempotencyKey string + IP string +} +type Factor struct { + ID string `bson:"id"` + Channel Channel `bson:"channel"` + Address string `bson:"address"` + Verified bool `bson:"verified"` +} +type Profile struct { + storable.Base `bson:",inline"` + Preferred string `bson:"preferred"` + Factors []Factor `bson:"factors"` + Revision string `bson:"revision"` + Fence string `bson:"fence"` +} + +func (*Profile) Collection() string { return "two_factor_profiles" } + +type Record struct { + storable.Base `bson:",inline"` + Subject bson.ObjectID `bson:"subject"` + Scope Scope `bson:"scope"` + Factor Factor `bson:"factor"` + ProfileVersion string `bson:"profileVersion"` + Locale string `bson:"locale"` + ExpiresAt time.Time `bson:"expiresAt"` + VerifiedAt *time.Time `bson:"verifiedAt,omitempty"` +} + +func (*Record) Collection() string { return "two_factor_challenges" } + +type Challenge struct { + ID string + Channel Channel + Target string + ExpiresAt, ResendAt time.Time +} +type Proof struct { + Token string + ExpiresAt time.Time +} +type Delivery struct { + Key string + Subject bson.ObjectID + Scope Scope + Factor Factor + Locale, Code string + ExpiresAt time.Time +} +type Choice struct { + ID string + Channel Channel + Target string +} +type Settings struct { + Required bool + Preferred string + Factors []Choice +} diff --git a/backend/product/twofactor/preferences.go b/backend/product/twofactor/preferences.go new file mode 100644 index 0000000..ba319b6 --- /dev/null +++ b/backend/product/twofactor/preferences.go @@ -0,0 +1,156 @@ +package twofactor + +import ( + "context" + "crypto/rand" + "errors" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + vmodel "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" + "go.mongodb.org/mongo-driver/v2/bson" + "time" +) + +func isRejection(err error) bool { + return errors.Is(err, verification.ErrTokenNotFound) || errors.Is(err, verification.ErrTokenAlreadyUsed) || errors.Is(err, verification.ErrTokenExpired) || errors.Is(err, verification.ErrTokenAttemptsExceeded) +} +func (s *Service) saveProfile(ctx context.Context, p *model.Profile) error { + _, err := s.store.Profile(ctx, p.ID) + if errors.Is(err, merrors.ErrNoData) { + return s.store.CreateProfile(ctx, p) + } + if err != nil { + return err + } + return s.store.UpdateProfile(ctx, p) +} +func (s *Service) Settings(ctx context.Context, subject bson.ObjectID) (model.Settings, error) { + _, p, _, err := s.profile(ctx, subject) + if err != nil { + return model.Settings{}, err + } + result := model.Settings{Required: true, Preferred: p.Preferred} + for _, f := range p.Factors { + if f.Verified { + if driver := s.drivers[f.Channel]; driver != nil { + result.Factors = append(result.Factors, model.Choice{ID: f.ID, Channel: f.Channel, Target: driver.Mask(f.Address)}) + } + } + } + return result, nil +} +func (s *Service) Enroll(ctx context.Context, subject bson.ObjectID, channel model.Channel, address, key, ip string) (model.Challenge, error) { + driver := s.drivers[channel] + if driver == nil { + return model.Challenge{}, merrors.NotImplemented("second-factor channel is not supported") + } + normalized, err := driver.Normalize(address) + if err != nil { + return model.Challenge{}, err + } + _, p, _, err := s.profile(ctx, subject) + if err != nil { + return model.Challenge{}, err + } + if len(p.Factors) >= 5 { + return model.Challenge{}, merrors.InvalidArgument("maximum enrolled factors reached") + } + for _, f := range p.Factors { + if f.Channel == channel && f.Address == normalized { + return model.Challenge{}, merrors.DataConflict("factor already enrolled") + } + } + f := model.Factor{ID: s.key("factor", subject.Hex(), string(channel), normalized)[:24], Channel: channel, Address: normalized} + scope := model.Scope{Purpose: vmodel.PurposeFactorEnrollment, ContextRef: "enroll:" + f.ID} + return s.begin(ctx, model.Request{Subject: subject, Scope: scope, IdempotencyKey: key, IP: ip}, &f) +} +func (s *Service) ConfirmEnrollment(ctx context.Context, subject bson.ObjectID, id, code string) (model.Settings, error) { + cid, err := bson.ObjectIDFromHex(id) + if err != nil { + return model.Settings{}, verification.ErorrTokenNotFound() + } + r, err := s.store.Challenge(ctx, cid) + if err != nil { + return model.Settings{}, err + } + if r.Subject != subject || r.Scope.Purpose != vmodel.PurposeFactorEnrollment { + return model.Settings{}, verification.ErorrTokenNotFound() + } + err = s.complete(ctx, subject, r.Scope, id, code, func(tx context.Context) error { + _, p, _, err := s.profile(tx, subject) + if err != nil { + return err + } + if len(p.Factors) >= 5 { + return merrors.InvalidArgument("maximum enrolled factors reached") + } + for _, f := range p.Factors { + if f.ID == r.Factor.ID { + return merrors.DataConflict("factor already enrolled") + } + } + factor := r.Factor + factor.Verified = true + p.Factors = append(p.Factors, factor) + p.Revision = rand.Text() + return s.saveProfile(tx, p) + }) + if err != nil { + return model.Settings{}, err + } + return s.Settings(ctx, subject) +} +func (s *Service) BeginPreference(ctx context.Context, subject bson.ObjectID, factorID, key, ip string) (model.Challenge, error) { + _, p, _, err := s.profile(ctx, subject) + if err != nil { + return model.Challenge{}, err + } + if !s.available(p, factorID) { + return model.Challenge{}, merrors.InvalidArgument("verified supported factor required") + } + return s.Begin(ctx, model.Request{Subject: subject, Scope: preferenceScope(factorID), IdempotencyKey: key, IP: ip, Deadline: time.Now().Add(TTL)}) +} +func (s *Service) ConfirmPreference(ctx context.Context, subject bson.ObjectID, factorID, id, code string) (model.Settings, error) { + scope := preferenceScope(factorID) + err := s.Complete(ctx, subject, scope, id, code, func(tx context.Context) error { + _, p, _, err := s.profile(tx, subject) + if err != nil { + return err + } + if !s.available(p, factorID) { + return merrors.InvalidArgument("verified supported factor required") + } + p.Preferred = factorID + p.Revision = rand.Text() + return s.saveProfile(tx, p) + }) + if err != nil { + return model.Settings{}, err + } + return s.Settings(ctx, subject) +} +func (s *Service) available(p *model.Profile, id string) bool { + for _, f := range p.Factors { + if f.ID == id && f.Verified && s.drivers[f.Channel] != nil { + return true + } + } + return false +} + +// ResendSettings cannot be used to send or authorize a pending login challenge. +func (s *Service) ResendSettings(ctx context.Context, subject bson.ObjectID, id, key, ip string) (model.Challenge, error) { + cid, err := bson.ObjectIDFromHex(id) + if err != nil { + return model.Challenge{}, verification.ErorrTokenNotFound() + } + r, err := s.store.Challenge(ctx, cid) + if err != nil { + return model.Challenge{}, err + } + if r.Subject != subject || (r.Scope.Purpose != vmodel.PurposeFactorEnrollment && r.Scope.Purpose != vmodel.PurposeFactorPreference) { + return model.Challenge{}, verification.ErorrTokenNotFound() + } + return s.Resend(ctx, model.Request{Subject: subject, Scope: r.Scope, IdempotencyKey: key, IP: ip}, id) +} diff --git a/backend/product/twofactor/proof.go b/backend/product/twofactor/proof.go new file mode 100644 index 0000000..7063e72 --- /dev/null +++ b/backend/product/twofactor/proof.go @@ -0,0 +1,153 @@ +package twofactor + +import ( + "context" + "crypto/rand" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + vmodel "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" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "strings" + "time" +) + +type verificationOutcome struct { + proof model.Proof + rejected error +} + +func (s *Service) Verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) { + if !validScope(scope) { + return model.Proof{}, merrors.InvalidArgument("unsupported second-factor operation scope") + } + return s.verify(ctx, subject, scope, id, code) +} +func (s *Service) verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) { + code = strings.TrimSpace(code) + if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return model.Proof{}, verification.ErorrTokenNotFound() + } + result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + r, err := s.record(tx, id, subject, scope) + if err != nil { + return nil, err + } + if r.VerifiedAt != nil { + return nil, verification.ErorrTokenAlreadyUsed() + } + // Commit rejected verification attempts while rolling back infrastructure + // failures. Sendico's Consume records misses in the enclosing transaction. + _, err = s.tokens.Consume(tx, subject, scope.Purpose, r.ID.Hex(), code) + if err != nil { + if isRejection(err) { + return verificationOutcome{rejected: err}, nil + } + return nil, err + } + ttl := min(proofTTL, time.Until(r.ExpiresAt)) + if ttl <= 0 { + return nil, verification.ErorrTokenExpired() + } + token, err := s.tokens.CreateWithResult(tx, verification.NewLinkRequest(subject, vmodel.PurposeFactorProof, r.ID.Hex()).WithContextRef(scopeKey(scope)).WithTTL(ttl).WithIdempotencyKey(s.key("proof", r.ID.Hex()))) + if err != nil { + return nil, err + } + now := time.Now().UTC() + r.VerifiedAt = &now + if err = s.store.UpdateChallenge(tx, r); err != nil { + return nil, err + } + return verificationOutcome{proof: model.Proof{Token: token.VerificationToken, ExpiresAt: token.ExpiresAt}}, nil + }) + if err != nil { + return model.Proof{}, err + } + outcome, ok := result.(verificationOutcome) + if !ok { + return model.Proof{}, merrors.Internal("invalid second-factor verification result") + } + if outcome.rejected != nil { + return model.Proof{}, outcome.rejected + } + s.logger.Debug("Second-factor proof prepared", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) + return outcome.proof, nil +} +func (s *Service) Consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error { + if !validScope(scope) { + return merrors.InvalidArgument("unsupported second-factor operation scope") + } + return s.consume(ctx, subject, scope, proof, action) +} +func (s *Service) consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error { + if action == nil || len(proof.Token) != 43 { + return merrors.InvalidArgument("proof and transactional action required") + } + _, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + token, err := s.tokens.Consume(tx, subject, vmodel.PurposeFactorProof, scopeKey(scope), proof.Token) + if err != nil { + return nil, err + } + r, err := s.record(tx, token.Target, subject, scope) + if err != nil { + return nil, err + } + if r.VerifiedAt == nil { + return nil, verification.ErorrTokenNotFound() + } + // Touch the profile in this transaction so a concurrent preference update + // conflicts and cannot let an old-factor proof authorize a later action. + _, p, _, err := s.profile(tx, subject) + if err != nil { + return nil, err + } + p.Fence = rand.Text() + if err = s.saveProfile(tx, p); err != nil { + return nil, err + } + return nil, action(tx) + }) + if err == nil { + s.logger.Debug("Second-factor proof consumption prepared", zap.String("purpose", string(scope.Purpose))) + } + return err +} + +// Complete atomically verifies the code, consumes its proof and commits the +// owning operation. A failed operation leaves the code retryable; a rejected +// code still commits its attempt counter. +func (s *Service) Complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error { + if !validScope(scope) { + return merrors.InvalidArgument("unsupported second-factor operation scope") + } + return s.complete(ctx, subject, scope, id, code, action) +} +func (s *Service) complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error { + result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { + proof, err := s.verify(tx, subject, scope, id, code) + if err != nil { + if isRejection(err) { + return verificationOutcome{rejected: err}, nil + } + return nil, err + } + if err = s.consume(tx, subject, scope, proof, action); err != nil { + return nil, err + } + return verificationOutcome{}, nil + }) + if err != nil { + return err + } + outcome, ok := result.(verificationOutcome) + if !ok { + return merrors.Internal("invalid second-factor completion result") + } + if outcome.rejected == nil { + s.logger.Info("Second-factor operation completed", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) + } else { + s.logger.Info("Second-factor code rejected", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose))) + } + return outcome.rejected +} diff --git a/backend/product/twofactor/service.go b/backend/product/twofactor/service.go new file mode 100644 index 0000000..61d5f16 --- /dev/null +++ b/backend/product/twofactor/service.go @@ -0,0 +1,159 @@ +// Package twofactor owns second-factor policy, challenges and one-use proofs. +// It deliberately has no dependency on auth HTTP, JWTs or session creation. +package twofactor + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + + customer "git.sendico.io/tech/sxchnge/product/customers/model" + account "git.sendico.io/tech/sxchnge/product/customers/store" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + vmodel "git.sendico.io/tech/sxchnge/product/verification/model" + verification "git.sendico.io/tech/sxchnge/product/verification/store" + "git.sendico.io/tech/sxchnge/shared/database/transaction" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const TTL = 10 * time.Minute +const Cooldown = time.Minute +const proofTTL = 2 * time.Minute +const primary = "primary-email" + +// API is consumed by auth and other operation owners. Complete joins code +// verification, proof consumption and the owner's local database action. +type API interface { + Begin(context.Context, model.Request) (model.Challenge, error) + Resend(context.Context, model.Request, string) (model.Challenge, error) + Complete(context.Context, bson.ObjectID, model.Scope, string, string, func(context.Context) error) error +} +type Store interface { + Profile(context.Context, bson.ObjectID) (*model.Profile, error) + CreateProfile(context.Context, *model.Profile) error + UpdateProfile(context.Context, *model.Profile) error + Challenge(context.Context, bson.ObjectID) (*model.Record, error) + CreateChallenge(context.Context, *model.Record) error + UpdateChallenge(context.Context, *model.Record) error +} +type Driver interface { + Channel() model.Channel + Normalize(string) (string, error) + Mask(string) string + Send(context.Context, model.Delivery) error +} +type Service struct { + accounts account.DB + tokens verification.DB + store Store + tx transaction.Factory + secret []byte + drivers map[model.Channel]Driver + logger mlogger.Logger +} + +func New(accounts account.DB, tokens verification.DB, store Store, tx transaction.Factory, secret []byte, logger mlogger.Logger, drivers ...Driver) (*Service, error) { + if accounts == nil || tokens == nil || store == nil || tx == nil || len(secret) < 32 || logger == nil { + return nil, merrors.InvalidArgument("2FA dependencies and private key material are required") + } + s := &Service{accounts: accounts, tokens: tokens, store: store, tx: tx, secret: append([]byte(nil), secret...), drivers: map[model.Channel]Driver{}, logger: logger.Named("twofactor")} + for _, d := range drivers { + if d == nil || s.drivers[d.Channel()] != nil { + return nil, merrors.InvalidArgument("unique second-factor channel drivers required") + } + s.drivers[d.Channel()] = d + } + if s.drivers[model.Email] == nil { + return nil, merrors.InvalidArgument("verified account email channel is required") + } + return s, nil +} +func (s *Service) key(parts ...string) string { + h := hmac.New(sha256.New, s.secret) + for _, p := range parts { + h.Write([]byte(p)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} +func validScope(scope model.Scope) bool { + if strings.TrimSpace(scope.ContextRef) == "" || len(scope.ContextRef) > 256 || strings.ContainsRune(scope.ContextRef, 0) { + return false + } + switch scope.Purpose { + case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference: + return true + } + return false +} +func scopeKey(scope model.Scope) string { return string(scope.Purpose) + ":" + scope.ContextRef } +func (s *Service) profile(ctx context.Context, id bson.ObjectID) (*customer.Account, *model.Profile, string, error) { + a := &customer.Account{} + if err := s.accounts.Get(ctx, id, a); err != nil { + return nil, nil, "", err + } + if !a.IsActive() || a.IsArchived() { + return nil, nil, "", merrors.Unauthorized("active verified account required") + } + p, err := s.store.Profile(ctx, id) + if errors.Is(err, merrors.ErrNoData) { + p = &model.Profile{Preferred: primary, Revision: "initial"} + p.ID = id + } else if err != nil { + return nil, nil, "", err + } + // The primary address is authorized by account activation, never by request data. + factors := []model.Factor{{ID: primary, Channel: model.Email, Address: a.Login, Verified: true}} + for _, f := range p.Factors { + if f.ID != primary { + factors = append(factors, f) + } + } + p.Factors = factors + version := s.key("profile", id.Hex(), a.Login, p.Revision) + return a, p, version, nil +} +func (s *Service) preferred(p *model.Profile) (model.Factor, error) { + for _, f := range p.Factors { + if f.ID == p.Preferred && f.Verified && s.drivers[f.Channel] != nil { + return f, nil + } + } + return model.Factor{}, merrors.Unauthorized("configured second factor is unavailable") +} +func (s *Service) record(ctx context.Context, idText string, subject bson.ObjectID, scope model.Scope) (*model.Record, error) { + id, err := bson.ObjectIDFromHex(idText) + if err != nil { + return nil, verification.ErorrTokenNotFound() + } + r, err := s.store.Challenge(ctx, id) + if errors.Is(err, merrors.ErrNoData) { + return nil, verification.ErorrTokenNotFound() + } + if err != nil { + return nil, err + } + if r.Subject != subject || r.Scope != scope { + return nil, verification.ErorrTokenNotFound() + } + if !r.ExpiresAt.After(time.Now()) { + return nil, verification.ErorrTokenExpired() + } + _, _, version, err := s.profile(ctx, subject) + if err != nil { + return nil, err + } + if version != r.ProfileVersion { + return nil, merrors.Unauthorized("second-factor settings changed; restart verification") + } + if s.drivers[r.Factor.Channel] == nil { + return nil, merrors.NotImplemented("second-factor channel unavailable") + } + return r, nil +} diff --git a/backend/product/twofactor/service_test.go b/backend/product/twofactor/service_test.go new file mode 100644 index 0000000..7f74473 --- /dev/null +++ b/backend/product/twofactor/service_test.go @@ -0,0 +1,235 @@ +package twofactor_test + +import ( + "context" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" + customer "git.sendico.io/tech/sxchnge/product/customers/model" + "git.sendico.io/tech/sxchnge/product/twofactor" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" + vmodel "git.sendico.io/tech/sxchnge/product/verification/model" + verification "git.sendico.io/tech/sxchnge/product/verification/store" + db "git.sendico.io/tech/sxchnge/shared/database" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + sharedmodel "git.sendico.io/tech/sxchnge/shared/model" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type capturedDriver struct { + deliveries []model.Delivery + fail bool +} + +func (*capturedDriver) Channel() model.Channel { return model.Email } +func (*capturedDriver) Normalize(s string) (string, error) { return customer.NormalizeEmail(s) } +func (*capturedDriver) Mask(string) string { return "masked" } +func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error { + d.deliveries = append(d.deliveries, m) + if d.fail { + return merrors.ServiceUnavailable("queue unavailable") + } + return nil +} +func TestServiceTransactionsAndFactors(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) + uri, err := container.ConnectionString(ctx) + require.NoError(t, err) + u, err := url.Parse(uri) + require.NoError(t, err) + q := u.Query() + q.Set("directConnection", "true") + u.RawQuery = q.Encode() + factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "twofactor"}}) + require.NoError(t, err) + t.Cleanup(factory.CloseConnection) + stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil) + require.NoError(t, err) + records, err := factorstore.Open(zap.NewNop(), factory) + require.NoError(t, err) + driver := &capturedDriver{} + service, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), []byte(strings.Repeat("s", 32)), zap.NewNop(), driver) + require.NoError(t, err) + account := func() *customer.Account { + a := &customer.Account{Status: customer.AccountActive} + a.ID = bson.NewObjectID() + a.Login = a.ID.Hex() + "@example.com" + a.Locale = "ru" + require.NoError(t, stores.Accounts.Create(ctx, a)) + return a + } + request := func(a *customer.Account) model.Request { + return model.Request{Subject: a.ID, Scope: model.Scope{Purpose: vmodel.PurposeLogin, ContextRef: bson.NewObjectID().Hex()}, IdempotencyKey: "begin", IP: a.ID.Hex()} + } + begin := func(r model.Request) (model.Challenge, string) { + c, err := service.Begin(ctx, r) + require.NoError(t, err) + return c, driver.deliveries[len(driver.deliveries)-1].Code + } + action := func(context.Context) error { return nil } + t.Run("delivery retry and immutable deadline", func(t *testing.T) { + a := account() + r := request(a) + r.Deadline = time.Now().Add(3 * time.Minute) + driver.fail = true + _, err := service.Begin(ctx, r) + require.ErrorIs(t, err, merrors.ErrServiceUnavailable) + first := driver.deliveries[len(driver.deliveries)-1] + driver.fail = false + c, code := begin(r) + require.Equal(t, first.Code, code) + require.Equal(t, first.Key, driver.deliveries[len(driver.deliveries)-1].Key) + require.WithinDuration(t, first.ExpiresAt, c.ExpiresAt, time.Millisecond) + require.False(t, c.ExpiresAt.After(r.Deadline)) + require.Equal(t, a.Login, first.Factor.Address) + require.Equal(t, "ru", first.Locale) + require.Equal(t, "masked", c.Target) + r.IdempotencyKey = "resend" + _, err = service.Resend(ctx, r, c.ID) + _, throttled := verification.Throttle(err) + require.True(t, throttled) + _, err = service.ResendSettings(ctx, a.ID, c.ID, "settings", r.IP) + require.ErrorIs(t, err, verification.ErrTokenNotFound) + }) + t.Run("scope and one use proof", func(t *testing.T) { + a := account() + r := request(a) + c, code := begin(r) + _, err := service.Verify(ctx, account().ID, r.Scope, c.ID, code) + require.ErrorIs(t, err, verification.ErrTokenNotFound) + other := r.Scope + other.ContextRef = "another-operation" + _, err = service.Verify(ctx, a.ID, other, c.ID, code) + require.ErrorIs(t, err, verification.ErrTokenNotFound) + other = r.Scope + other.Purpose = vmodel.PurposePayout + _, err = service.Verify(ctx, a.ID, other, c.ID, code) + require.ErrorIs(t, err, verification.ErrTokenNotFound) + proof, err := service.Verify(ctx, a.ID, r.Scope, c.ID, code) + require.NoError(t, err) + require.Len(t, proof.Token, 43) + require.Error(t, service.Consume(ctx, a.ID, other, proof, action)) + require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, func(context.Context) error { return merrors.ServiceUnavailable("operation failed") }), merrors.ErrServiceUnavailable) + require.NoError(t, service.Consume(ctx, a.ID, r.Scope, proof, action)) + require.Error(t, service.Consume(ctx, a.ID, r.Scope, proof, action)) + _, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code) + require.ErrorIs(t, err, verification.ErrTokenAlreadyUsed) + }) + t.Run("atomic operation rollback and concurrent completion", func(t *testing.T) { + a := account() + r := request(a) + c, code := begin(r) + err := service.Complete(ctx, a.ID, r.Scope, c.ID, code, func(tx context.Context) error { + updated := &customer.Account{} + if err := stores.Accounts.Get(tx, a.ID, updated); err != nil { + return err + } + updated.Name = "should rollback" + if err := stores.Accounts.Update(tx, updated); err != nil { + return err + } + return merrors.ServiceUnavailable("session write failed") + }) + require.ErrorIs(t, err, merrors.ErrServiceUnavailable) + saved := &customer.Account{} + require.NoError(t, stores.Accounts.Get(ctx, a.ID, saved)) + require.Empty(t, saved.Name) + id, err := bson.ObjectIDFromHex(c.ID) + require.NoError(t, err) + record, err := records.Challenge(ctx, id) + require.NoError(t, err) + require.Nil(t, record.VerifiedAt) + var successes atomic.Int32 + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + if service.Complete(ctx, a.ID, r.Scope, c.ID, code, action) == nil { + successes.Add(1) + } + }() + } + wg.Wait() + require.EqualValues(t, 1, successes.Load()) + }) + t.Run("bad attempts are committed even with an outer operation", func(t *testing.T) { + a := account() + r := request(a) + c, code := begin(r) + wrong := "000000" + if code == wrong { + wrong = "111111" + } + for range 5 { + require.Error(t, service.Complete(ctx, a.ID, r.Scope, c.ID, wrong, action)) + } + require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), verification.ErrTokenAttemptsExceeded) + }) + t.Run("expired and inactive challenges fail closed", func(t *testing.T) { + a := account() + r := request(a) + c, code := begin(r) + id, err := bson.ObjectIDFromHex(c.ID) + require.NoError(t, err) + record, err := records.Challenge(ctx, id) + require.NoError(t, err) + record.ExpiresAt = time.Now().Add(-time.Second) + require.NoError(t, records.UpdateChallenge(ctx, record)) + _, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code) + require.ErrorIs(t, err, verification.ErrTokenExpired) + a = account() + r = request(a) + c, code = begin(r) + a.Status = customer.AccountBlocked + require.NoError(t, stores.Accounts.Update(ctx, a)) + require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), merrors.ErrUnauthorized) + }) + t.Run("verified preference controls delivery and invalidates old proofs", func(t *testing.T) { + a := account() + r := request(a) + old, oldCode := begin(r) + proof, err := service.Verify(ctx, a.ID, r.Scope, old.ID, oldCode) + require.NoError(t, err) + _, err = service.Enroll(ctx, a.ID, "sms", "+10000000000", "sms", r.IP) + require.ErrorIs(t, err, merrors.ErrNotImplemented) + enroll, err := service.Enroll(ctx, a.ID, model.Email, "SECOND@example.com", "enroll", r.IP) + require.NoError(t, err) + code := driver.deliveries[len(driver.deliveries)-1].Code + require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address) + settings, err := service.Settings(ctx, a.ID) + require.NoError(t, err) + require.Len(t, settings.Factors, 1) + settings, err = service.ConfirmEnrollment(ctx, a.ID, enroll.ID, code) + require.NoError(t, err) + require.Len(t, settings.Factors, 2) + require.Equal(t, "primary-email", settings.Preferred) + require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, action), merrors.ErrUnauthorized) + selected := settings.Factors[1].ID + change, err := service.BeginPreference(ctx, a.ID, selected, "preference", r.IP) + require.NoError(t, err) + d := driver.deliveries[len(driver.deliveries)-1] + require.Equal(t, a.Login, d.Factor.Address) + _, err = service.ConfirmPreference(ctx, a.ID, "primary-email", change.ID, d.Code) + require.ErrorIs(t, err, verification.ErrTokenNotFound) + settings, err = service.ConfirmPreference(ctx, a.ID, selected, change.ID, d.Code) + require.NoError(t, err) + require.Equal(t, selected, settings.Preferred) + next := request(a) + _, _ = begin(next) + require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address) + }) +} diff --git a/backend/product/twofactor/store/mongo.go b/backend/product/twofactor/store/mongo.go new file mode 100644 index 0000000..1a7ccb9 --- /dev/null +++ b/backend/product/twofactor/store/mongo.go @@ -0,0 +1,53 @@ +package store + +import ( + "context" + model "git.sendico.io/tech/sxchnge/product/twofactor/model" + db "git.sendico.io/tech/sxchnge/shared/database" + deps "git.sendico.io/tech/sxchnge/shared/database/dependencies" + ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" + "git.sendico.io/tech/sxchnge/shared/database/template" + mlogger "git.sendico.io/tech/sxchnge/shared/logging" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Store struct { + profiles *template.DBImp[*model.Profile] + challenges *template.DBImp[*model.Record] +} + +func Open(logger mlogger.Logger, factory db.Factory) (*Store, error) { + d, err := deps.ResolveDependencies(factory) + if err != nil { + return nil, err + } + s := &Store{profiles: template.Create[*model.Profile](logger, "two_factor_profiles", d.Database), challenges: template.Create[*model.Record](logger, "two_factor_challenges", d.Database)} + retention := int32(86400) + err = s.challenges.Repository.CreateIndex(&ri.Definition{Name: "two_factor_expiry", Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, TTL: &retention}) + if err != nil { + return nil, err + } + return s, nil +} +func (s *Store) Profile(ctx context.Context, id bson.ObjectID) (*model.Profile, error) { + p := &model.Profile{} + err := s.profiles.Get(ctx, id, p) + return p, err +} +func (s *Store) CreateProfile(ctx context.Context, p *model.Profile) error { + return s.profiles.Create(ctx, p) +} +func (s *Store) UpdateProfile(ctx context.Context, p *model.Profile) error { + return s.profiles.Update(ctx, p) +} +func (s *Store) Challenge(ctx context.Context, id bson.ObjectID) (*model.Record, error) { + r := &model.Record{} + err := s.challenges.Get(ctx, id, r) + return r, err +} +func (s *Store) CreateChallenge(ctx context.Context, r *model.Record) error { + return s.challenges.Create(ctx, r) +} +func (s *Store) UpdateChallenge(ctx context.Context, r *model.Record) error { + return s.challenges.Update(ctx, r) +} diff --git a/backend/product/verification/model/verificaton.go b/backend/product/verification/model/verificaton.go index 388691f..cfa997b 100644 --- a/backend/product/verification/model/verificaton.go +++ b/backend/product/verification/model/verificaton.go @@ -17,6 +17,9 @@ const ( PurposePasswordReset VerificationPurpose = "password_reset" PurposeMagicLogin VerificationPurpose = "magic_login" PurposeLogin VerificationPurpose = "login" + PurposeFactorEnrollment VerificationPurpose = "factor_enrollment" + PurposeFactorPreference VerificationPurpose = "factor_preference" + PurposeFactorProof VerificationPurpose = "factor_proof" PurposePayout VerificationPurpose = "payout" ) @@ -36,6 +39,12 @@ func VPFromString(s string) (VerificationPurpose, error) { return PurposeMagicLogin, nil case string(PurposeLogin): return PurposeLogin, nil + case string(PurposeFactorEnrollment): + return PurposeFactorEnrollment, nil + case string(PurposeFactorPreference): + return PurposeFactorPreference, nil + case string(PurposeFactorProof): + return PurposeFactorProof, nil case string(PurposePayout): return PurposePayout, nil default: diff --git a/docs/architecture/two-factor.md b/docs/architecture/two-factor.md new file mode 100644 index 0000000..bce739e --- /dev/null +++ b/docs/architecture/two-factor.md @@ -0,0 +1,155 @@ +# Second-factor service + +Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`. + +## Ownership + +`backend/product/twofactor` is a domain service inside the product executable. +It owns verified factors, preferred delivery channels, challenge snapshots, +issuance limits, code verification and single-use authorization proofs. +Its domain has no dependency on auth HTTP, JWTs or sessions. Shared Mongo +wrappers supply persistence and transactions; notifications only deliver messages. + +Auth verifies the password, account and client, creates a pending JWT bound to the +client/device, and passes its hash as the login operation context. The service +selects the account's verified preferred factor and returns a masked challenge. +Auth calls `Complete` with the code and a callback that creates the session. +The service verifies the code, issues and consumes a proof, and commits the +callback's database changes in one transaction. Rejected guesses commit attempt +counters; callback failures roll back the code, proof and session together. +The HTTP response is written only after commit. + +`Verify` and `Consume` also support separate domain steps for an operation that +needs a short-lived proof. Proofs bind the customer, purpose, operation and +challenge; they expire within two minutes and never outlive the challenge. +`Consume` checks the current profile and consumes the proof with the owner's +callback. Callbacks must only use this shared Mongo transaction: no external I/O, +response writes or irreversible side effects. Mongo may retry the callback. +An eventual remote service would need an explicit distributed completion protocol; +the callback contract cannot simply be translated into an RPC. + +Signup email activation remains in `auth/flow`, with separate purpose and link +semantics. API-client secret login retains its existing separate behavior. +No generic public endpoint can choose an arbitrary authorization purpose. + +## Sendico reuse + +The token engine is the existing locally owned copy in +`backend/product/verification/store`. Its source is Sendico +`S_I/sendico` commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`, already recorded +in `backend/PROVENANCE.json` and `backend/PROVENANCE.md`. The new service uses +`NewOTPRequest`, `NewLinkRequest`, `CreateWithResult`, `Consume`, context binding, +idempotency, cooldown, max retries and issuance limits from that engine. It +retains SXCHNGE's subsequent random-key, empty-target-query and safe-log fixes. +No alternative OTP generator or token-consumption database was introduced. + +The committed Sendico handlers inspected under +`api/edge/product/internal/server/verificationimp/` include `request.go`, +`verify.go`, `sendcode.go`, `target.go`, `service.go`, `types.go` and `token.go`. +They mix verification with session creation and private platform messaging and +allow recipient resolution unsuitable for a verified-factor boundary. Those +handlers were not copied. The orchestration, profile store, delivery interface +and HTTP settings adapter are new local code. There is no Sendico runtime, +private messaging or database dependency. + +## Factors and policy + +An active account's activated primary email is the initial verified factor. +Profiles are persisted in `two_factor_profiles`; challenge snapshots in +`two_factor_challenges` bind the chosen factor, locale, profile revision and +operation deadline. Snapshots cannot redirect a code during resend. Profile +changes invalidate outstanding challenges and proofs. A transactional profile +write serializes proof consumption against concurrent preference changes. +Challenge documents expire through a TTL index one day after their logical expiry; +logical expiry is checked synchronously and does not depend on TTL cleanup. +No existing customer migration is required: the initial profile is resolved +from the verified account and persisted on first completed verification. + +Email is the installed channel driver. Users can enroll an additional email, +prove ownership and select it. Selection requires a fresh code at the current +preferred factor; enrollment alone cannot replace it. At most five factors +(including the primary email) are enrolled. Registered drivers form the supported +channel allow-list; unsupported or unavailable channels fail closed. There is no +automatic fallback, disable endpoint, factor removal or account recovery flow. +SMS, push, authenticator apps and passkeys require concrete implementations and +client support before use. The Flutter settings screen is still the existing +preview; these authenticated settings APIs are ready for a separate UI adapter. + +Login, payout and preference scopes are recognized internally. Payout integration +is not added here; the payout owner must validate its immutable operation details +and call this service. An auth proof cannot authorize money operations. + +## HTTP settings contract + +All routes below are under `/api/v1/accounts` and require an existing active +account session through the standard `AccountHandler`. Pending login credentials +cannot access them. Account identity and operation scope come from the server, +not request fields. Responses use the existing `data` envelope and `no-store`. + +| Method/path | Body | Result (202) | +| --- | --- | --- | +| GET `/two-factor` | none | `required: true`, `preferred`, `factors: [{id, channel, target}]` | +| POST `/two-factor/enroll` | `channel`, `address`, `idempotencyKey` | Challenge at the proposed address | +| POST `/two-factor/enroll/verify` | `challengeId`, `code` | Settings after ownership verification | +| POST `/two-factor/preference` | `factorId`, `idempotencyKey` | Challenge at the current preferred factor | +| POST `/two-factor/preference/verify` | `factorId`, `challengeId`, `code` | Settings after preference changes | +| POST `/two-factor/resend` | `challengeId`, `idempotencyKey` | Same settings operation and target, renewed code | + +Challenge responses contain `challengeId`, `channel`, masked `target`, `expiresAt` +and `resendAt`. Login responses additionally include the pending JWT; login +resend/verify now require both `challengeId` and `pendingToken`. Codes, raw proofs +and full enrolled addresses are never returned in these responses. + +Codes expire within ten minutes with five guesses per issued code. New issuance +uses a one-minute cooldown per challenge, plus shared fixed-window limits of five +per customer and twenty per requesting IP per hour across purposes. New challenges +cannot reset these delivery budgets. Same-key delivery retries recover the same +code, notification key and expiry; notification enqueueing deduplicates them. +A new accepted resend invalidates the prior code. A locked/expired operation must +restart. Replaying a used code or proof cannot execute the callback again. + +## Rollout and recovery + +Load revision 1 of `twofactor.code` alongside `auth.activation` and `auth.login` +from `backend/notifications/templates.example.json`; all have English/Russian +variants. Existing notification TLS/caller configuration and the private signing +key supply delivery and secret-derived issuance keys. No new service deployment +or network credentials are required. Mongo must support transactions (replica set). +The application creates the two new collections/index on startup. + +Deploy backend and Flutter contract changes together. Earlier pending login codes +have no new challenge record and must restart password login after rollout. +Existing sessions and activation links are preserved. Key rotation invalidates +outstanding factor snapshots/proofs. Keep factors on an available driver when +changing configured drivers; there is no silent fallback. + +A failed session write permits retrying the same code. A lost HTTP response after +a successful commit cannot replay the operation: restart login. Enrollment and +preference confirmation can be reconciled with GET settings after an ambiguous +response. Tokens and challenges commit before notification Send; a product crash +before Send still requires resend. No product delivery outbox or inbox receipts +are claimed. No live emails were sent during tests. + +## Verification and observability + +Real Mongo tests cover failed delivery replay, scope/customer isolation, expiry, +inactive accounts, attempts committed through outer transactions, proof replay, +callback rollback, concurrent completion, enrollment and preference invalidation. +The signup/login integration test exercises the new auth boundary. Transport +checks reject caller identity/purpose/destination overrides. Architecture tests +prevent the 2FA domain importing auth or sessions. Flutter adapter tests exercise +server challenge binding, resend recovery and unsupported channels. + +Existing auth HTTP counters/timers and frontend journey events remain authoritative +for login outcomes. Domain logs record queued challenges, rejected codes and +completed operations with challenge IDs and purpose/channel, without codes, +proofs, keys or destinations. Prepared proof logs are debug-level and do not +represent a committed operation. See `docs/ux/auth-journeys.md` for attempt windows, +retry/abandonment rules and the existing production analytics export gap. +No new settings UI journey or production analytics sink is claimed. + +The UI composition is unchanged. The shared DTO/mapper/service adapter preserves +masked delivery targets, provider error/retry handling and in-memory credential +storage. Existing widget tests cover mobile forms, keyboard behavior, loading, +cooldown and recovery; new adapter tests cover the changed backend contract. +Required completion checks and delivery are recorded in `docs/implementation.md`. diff --git a/docs/auth-notifications.md b/docs/auth-notifications.md index 3e8ff02..c0cf038 100644 --- a/docs/auth-notifications.md +++ b/docs/auth-notifications.md @@ -2,14 +2,14 @@ Task [#38](https://git.sendico.io/tech/sxchnge/issues/38), [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39), branch `auth-38`. Primary module: auth; secondary modules: notification templates and Flutter auth. -This change stacks on notifications PR #37, which stacks on BFF PR #35. -Merge #35, then #37, then this contribution, retargeting each dependent PR to main. +The initial integration is merged. Task #40 separates login verification into +the [second-factor service](architecture/two-factor.md). ## Configuration -Load the `auth.activation` and `auth.login` revision 1 templates from +Load the `auth.activation`, `auth.login` and `twofactor.code` revision 1 templates from `backend/notifications/templates.example.json` into the notification catalog. -Both templates have English and Russian variants; product account locale selects +These templates have English and Russian variants; product account locale selects Russian for `ru` variants and English otherwise. Publish subsequent template changes under a new revision rather than mutating deployed content. @@ -66,9 +66,9 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies. | POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance | | POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts | | POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits | -| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, masked `target`, `expiresAt`, `resendAt` | -| POST `/login/resend` | `pendingToken`, `idempotencyKey` | 202 with the challenge timing and same pending credential | -| POST `/login/verify` | `pendingToken`, six-digit `code` | 200 with account and existing access/refresh response | +| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` | +| POST `/login/resend` | `pendingToken`, `challengeId`, `idempotencyKey` | 202 with the challenge timing and same pending credential | +| POST `/login/verify` | `pendingToken`, `challengeId`, six-digit `code` | 200 with account and existing access/refresh response | The pending JWT binds the customer, client and device. The verification record binds its code to a hash of that JWT. Password login does not create a session. @@ -79,7 +79,8 @@ and commits the account transition with token consumption in one transaction. Login codes expire within 10 minutes and cannot outlive their pending JWT. Resending uses a one-minute cooldown, invalidates the previous code in that -challenge, and allows five deliveries per destination per hour. Incorrect codes +challenge, and allows five deliveries per customer and twenty per requesting IP per hour +across second-factor purposes. Incorrect codes consume the existing five-attempt budget. Restart login after a pending credential expires or a challenge locks. Signup/resend request limits also apply per email (10/hour) and IP (30/hour), using shared atomic fixed-window counters. @@ -102,8 +103,9 @@ retries can create replacement messages; they are not distributed exactly-once operations. Tokens are stored before the RPC, so a process crash between those steps requires user recovery; there is no product-side delivery outbox. -Code consumption precedes session creation. If session creation or its HTTP -response fails, restart password login; the code is not reusable. The adapter +Code verification, one-use proof consumption and session creation now commit in +one transaction. A session-write failure leaves the code retryable; a lost HTTP +response after commit requires restarting password login. The adapter retains a confirmed session in memory and has no refresh/restore-on-reload flow in this contribution. Backend authorization remains authoritative for future protected APIs. Notification provider failures after queue acceptance are visible diff --git a/docs/implementation.md b/docs/implementation.md index 7229f5c..7707ab4 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -536,3 +536,47 @@ frontend API URL/public client before enabling real auth. Delivered in [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39). Merge order: #35 → #37 → #39. The auth branch incorporates notification completion commit `4b14649`; only the delivery-log append conflicted, and both task records are preserved. + +## Dedicated second-factor service (#40) + +Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`, +based on main after merged PR #39. Primary module: twofactor; secondary modules: +auth, verification purpose definitions, notification templates and Flutter auth. + +`product/twofactor` now owns challenge issuance/delivery, verified destinations, +preferences, attempt/cooldown limits and single-use proofs. It reuses the +Sendico-derived verification store at the existing pinned revision. No new +Sendico handler or private messaging code was copied. Auth owns credentials, +pending JWTs and session creation; signup activation remains in `auth/flow`. +The service's `Complete` transaction commits code verification, proof consumption +and the owner's session write together. Review fixed rejected-attempt rollback, +ensured failed session writes preserve retryability, retained activation replay +key compatibility and removed the unused verification-store injection from auth +HTTP. A profile write serializes consumption against preference changes. + +Authenticated APIs enroll additional verified emails and change the preferred +factor only after verifying the current factor. Unsupported channels fail closed. +Flutter's shared DTO/mapper/service adapter sends the server challenge ID with +the pending JWT, preserves resend keys after failure and rejects unknown channels. +No widgets or provider/analytics boundaries changed. Existing login journey +measurement and error/recovery UI remain in use; settings UI integration is not +part of this separation. + +Validation passed: `make test` (all Go race suites and `make test-frontend`), +`make lint-backend`, `flutter analyze` in `frontend/packages/sxchnge_auth`, and +Dart format verification for all four changed Dart files. New real Mongo tests +cover single-use proofs, customer/purpose/context isolation, delivery retries, +committed failed guesses, expiry, inactive accounts, operation rollback, +concurrent completion, verified enrollment, preference selection and old-proof +invalidation. The updated signup/login test covers the full auth boundary; +transport and architecture tests cover scope injection and domain dependencies. +Frontend tests cover challenge binding, resend recovery and unsupported channels. +No live email or optional security scan was run. + +See [ownership, Sendico provenance, HTTP contract and rollout](architecture/two-factor.md). +Deploy backend and client together, load `twofactor.code` revision 1, and restart +previously pending logins after rollout. Email is the implemented channel. +Existing settings screens still use preview adapters; SMS/push/TOTP/passkeys, +factor removal/recovery, payout integration and a separate process are not +implemented. Successful-commit response loss still requires login restart; +notification queue acceptance is not inbox delivery. diff --git a/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart b/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart index 510c435..4d511e7 100644 --- a/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart @@ -13,6 +13,8 @@ class BackendDTO { ''; String get pendingToken => (data['pendingToken'] as Map)['token'] as String; + String get challengeId => data['challengeId'] as String? ?? ''; + String get channel => data['channel'] as String? ?? ''; String get target => data['target'] as String; DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String); DateTime get resendAt => DateTime.parse(data['resendAt'] as String); diff --git a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart index 51af302..27fb6bc 100644 --- a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart @@ -9,15 +9,20 @@ abstract final class BackendMapper { return AuthResult(userId: dto.userId, isSimulated: false); } - static FactorChallenge challenge(BackendDTO dto, String id) => - FactorChallenge( - id: id, - purpose: FactorPurpose.login, - operation: id, - target: dto.target, - expiresAt: dto.expiresAt, - resendAt: dto.resendAt, - ); + static FactorChallenge challenge(BackendDTO dto, String id) { + if (dto.challengeId.isEmpty || dto.channel != 'email') { + throw const FactorException(FactorError.unavailable); + } + return FactorChallenge( + id: id, + purpose: FactorPurpose.login, + operation: id, + target: dto.target, + expiresAt: dto.expiresAt, + resendAt: dto.resendAt, + ); + } + static AuthSession session(BackendDTO dto) { if (dto.accessToken.isEmpty || dto.userId.isEmpty) { throw const FormatException('Missing session'); diff --git a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart index 432a3e5..ca92944 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart @@ -48,7 +48,7 @@ class BackendAuthServices final String clientId; final http.Client _client; final String _deviceId = _key(); - final _pending = {}; + final _pending = {}; final _grants = {}; final _retryKeys = {}; String? _queuedSignup; @@ -125,7 +125,7 @@ class BackendAuthServices }); final id = _key(); final challenge = BackendMapper.challenge(dto, id); - _pending[id] = dto.pendingToken; + _pending[id] = (token: dto.pendingToken, challengeId: dto.challengeId); return AuthResult(userId: '', isSimulated: false, challenge: challenge); } @@ -185,11 +185,16 @@ class BackendAuthServices if (pending == null) throw const FactorException(FactorError.expired); final key = _retryKeys.putIfAbsent('login:$challengeId', _key); final dto = await _post('login/resend', { - 'pendingToken': pending, + 'pendingToken': pending.token, + 'challengeId': pending.challengeId, 'idempotencyKey': key, }, factor: true); + final challenge = BackendMapper.challenge(dto, challengeId); + if (dto.challengeId != pending.challengeId) { + throw const FactorException(FactorError.unavailable); + } _retryKeys.remove('login:$challengeId'); - return BackendMapper.challenge(dto, challengeId); + return challenge; } @override @@ -197,7 +202,8 @@ class BackendAuthServices final pending = _pending[challengeId]; if (pending == null) throw const FactorException(FactorError.expired); final dto = await _post('login/verify', { - 'pendingToken': pending, + 'pendingToken': pending.token, + 'challengeId': pending.challengeId, 'code': code, }, factor: true); final session = BackendMapper.session(dto); diff --git a/frontend/packages/sxchnge_auth/test/backend_test.dart b/frontend/packages/sxchnge_auth/test/backend_test.dart index e8d8520..b67bd95 100644 --- a/frontend/packages/sxchnge_auth/test/backend_test.dart +++ b/frontend/packages/sxchnge_auth/test/backend_test.dart @@ -56,6 +56,8 @@ void main() { return response({ 'userId': 'user', 'pendingToken': {'token': 'signed-pending'}, + 'challengeId': 'server-challenge', + 'channel': 'email', 'target': 'u***@example.com', 'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(), 'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(), @@ -63,6 +65,7 @@ void main() { } expect(request.url.path, '/api/v1/accounts/login/verify'); expect(body['pendingToken'], 'signed-pending'); + expect(body['challengeId'], 'server-challenge'); if (body['code'] != '123456') return response({'error': 'invalid'}, 400); verified = true; @@ -145,4 +148,75 @@ void main() { ), ); }); + test( + 'resend preserves server challenge and retries the same delivery key', + () async { + final now = DateTime.now(); + final keys = []; + Map challenge() => { + 'userId': 'user', + 'pendingToken': {'token': 'pending'}, + 'challengeId': 'server-challenge', + 'channel': 'email', + 'target': 's***@example.com', + 'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(), + 'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(), + }; + final service = BackendAuthServices( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient((request) async { + if (request.url.path.endsWith('/login')) return response(challenge()); + final body = jsonDecode(request.body); + expect(body['challengeId'], 'server-challenge'); + expect(body['pendingToken'], 'pending'); + keys.add(body['idempotencyKey'] as String); + if (keys.length == 1) return response({'error': 'unavailable'}, 503); + return response(challenge()); + }), + ); + addTearDown(service.close); + final result = await service.login( + const LoginRequest( + email: 'user@example.com', + password: 'password', + locale: 'en', + ), + ); + final id = result.challenge!.id; + await expectLater(service.resend(id), throwsA(isA())); + final resent = await service.resend(id); + expect(resent.id, id); + expect(resent.target, 's***@example.com'); + expect(keys[0], keys[1]); + expect(service.session, isNull); + }, + ); + for (final channel in ['sms', '']) { + test( + 'unsupported or missing channel $channel cannot create a login challenge', + () async { + final service = BackendAuthServices( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient( + (_) async => + response({'challengeId': 'server', 'channel': channel}), + ), + ); + addTearDown(service.close); + await expectLater( + service.login( + const LoginRequest( + email: 'user@example.com', + password: 'password', + locale: 'en', + ), + ), + throwsA(isA()), + ); + expect(service.session, isNull); + }, + ); + } }