Separate second-factor verification and delivery from authentication #41
No files matched your search
@@ -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
|
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.
|
removed. Future public adapters will own just the wire types they consume.
|
||||||
Backoffice is a separate future executable module, not an alias for product.
|
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).
|
||||||
@@ -55,5 +55,25 @@
|
|||||||
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"subject": "Код подтверждения SXCHNGE",
|
||||||
|
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
|
||||||
|
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -9,11 +9,16 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
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"
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
"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/database"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
@@ -148,6 +153,7 @@ func CreateMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var authFlow *flow.Service
|
var authFlow *flow.Service
|
||||||
|
var factors *twofactor.Service
|
||||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||||
c := notificationConfig[0]
|
c := notificationConfig[0]
|
||||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||||
@@ -163,17 +169,33 @@ func CreateMiddleware(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
authFlow.Sender = p.notifications
|
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.epdispatcher = httprouter.NewDispatcher(
|
||||||
p.logger,
|
p.logger,
|
||||||
p.router,
|
p.router,
|
||||||
stores.Accounts,
|
stores.Accounts,
|
||||||
stores.Verification,
|
|
||||||
stores.RefreshTokens,
|
stores.RefreshTokens,
|
||||||
stores.LoginAttempts,
|
stores.LoginAttempts,
|
||||||
config,
|
config,
|
||||||
loginRateLimits,
|
loginRateLimits,
|
||||||
authFlow,
|
flows,
|
||||||
)
|
)
|
||||||
|
if factors != nil {
|
||||||
|
factorhttp.Install(p.epdispatcher, factors, p.logger)
|
||||||
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package flow
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net/mail"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,7 +25,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const Cooldown = time.Minute
|
const Cooldown = time.Minute
|
||||||
const LoginTTL = 10 * time.Minute
|
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
@@ -49,14 +47,6 @@ func (s *Service) Validate() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *Service) key(parts ...string) string {
|
||||||
h := hmac.New(sha256.New, s.Secret)
|
h := hmac.New(sha256.New, s.Secret)
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
@@ -83,7 +73,7 @@ func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)})
|
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 {
|
} else {
|
||||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
}
|
}
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||||
email, err := NormalizeEmail(email)
|
email, err := customer.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
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 {
|
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)
|
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
||||||
req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5)
|
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||||
if purpose == verificationmodel.PurposeAccountActivation {
|
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||||
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))
|
|
||||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
if !result.ExpiresAt.After(now) {
|
if !result.ExpiresAt.After(time.Now()) {
|
||||||
return time.Time{}, verification.ErorrTokenExpired()
|
return time.Time{}, verification.ErorrTokenExpired()
|
||||||
}
|
}
|
||||||
locale := "en"
|
locale := "en"
|
||||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||||
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}}
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
if purpose == verificationmodel.PurposeAccountActivation {
|
q := u.Query()
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
q.Set("token", result.VerificationToken)
|
||||||
q := u.Query()
|
u.RawQuery = q.Encode()
|
||||||
q.Set("token", result.VerificationToken)
|
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()}})
|
||||||
u.RawQuery = q.Encode()
|
return result.ExpiresAt, err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||||
if len(raw) != 43 {
|
if len(raw) != 43 {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"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"
|
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -39,26 +38,26 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
|||||||
expiry := time.Now().UTC().Add(time.Minute)
|
expiry := time.Now().UTC().Add(time.Minute)
|
||||||
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
||||||
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
|
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 := &customer.Account{}
|
||||||
a.ID = bson.NewObjectID()
|
a.ID = bson.NewObjectID()
|
||||||
a.Login = "user@example.com"
|
a.Login = "user@example.com"
|
||||||
a.Locale = "ru-RU"
|
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)
|
require.Error(t, err)
|
||||||
key := *tokens.request.IdempotencyKey
|
key := *tokens.request.IdempotencyKey
|
||||||
require.NotEqual(t, "client-key", key)
|
require.NotEqual(t, "client-key", key)
|
||||||
require.Equal(t, "pending-scope", tokens.request.ContextRef)
|
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
|
||||||
require.LessOrEqual(t, tokens.request.Ttl, time.Minute)
|
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
|
||||||
sender.err = nil
|
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.NoError(t, err)
|
||||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||||
service.Secret = []byte(strings.Repeat("b", 32))
|
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)
|
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.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||||
require.Len(t, sender.messages, 2)
|
require.Len(t, sender.messages, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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"
|
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/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"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||||
@@ -40,12 +38,11 @@ func NewDispatcher(
|
|||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
db account.DB,
|
db account.DB,
|
||||||
vdb verification.DB,
|
|
||||||
rtdb refreshtokens.DB,
|
rtdb refreshtokens.DB,
|
||||||
attempts ratelimits.Store,
|
attempts ratelimits.Store,
|
||||||
config *middleware.Config,
|
config *middleware.Config,
|
||||||
loginRateLimits middleware.LoginRateLimits,
|
loginRateLimits middleware.LoginRateLimits,
|
||||||
flows ...*flow.Service,
|
flows ...rpublic.Flows,
|
||||||
) *Dispatcher {
|
) *Dispatcher {
|
||||||
d := &Dispatcher{
|
d := &Dispatcher{
|
||||||
logger: logger.Named("api_dispatcher"),
|
logger: logger.Named("api_dispatcher"),
|
||||||
@@ -55,7 +52,7 @@ func NewDispatcher(
|
|||||||
endpoint := os.Getenv(config.EndPointEnv)
|
endpoint := os.Getenv(config.EndPointEnv)
|
||||||
signature := middleware.SignatureConf(config)
|
signature := middleware.SignatureConf(config)
|
||||||
router.Group(func(r chi.Router) {
|
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) {
|
router.Group(func(r chi.Router) {
|
||||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
|||||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
if a.flow != nil {
|
if a.factors != nil {
|
||||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||||
}
|
}
|
||||||
return a.respondPendingLogin(account)
|
return a.respondPendingLogin(account)
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
|||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/",
|
"/",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
nil,
|
|
||||||
rtdb,
|
rtdb,
|
||||||
nil,
|
nil,
|
||||||
chi.NewRouter(),
|
chi.NewRouter(),
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
|||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
nil,
|
|
||||||
rtdb,
|
rtdb,
|
||||||
nil,
|
nil,
|
||||||
chi.NewRouter(),
|
chi.NewRouter(),
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
|||||||
session *sessionmodel.SessionIdentifier,
|
session *sessionmodel.SessionIdentifier,
|
||||||
account *customermodel.Account,
|
account *customermodel.Account,
|
||||||
) http.HandlerFunc {
|
) 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(
|
refreshToken, err := rtokens.PrepareRefreshToken(
|
||||||
ctx,
|
ctx,
|
||||||
r,
|
r,
|
||||||
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
|
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))
|
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)
|
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
|
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))
|
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 := sresponse.TokenData{
|
||||||
Token: refreshToken.RefreshToken,
|
Token: refreshToken.RefreshToken,
|
||||||
Expiration: refreshToken.ExpiresAt,
|
Expiration: refreshToken.ExpiresAt,
|
||||||
}
|
}
|
||||||
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/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"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||||
@@ -17,7 +17,13 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Flows struct {
|
||||||
|
Onboarding *flow.Service
|
||||||
|
Factors twofactor.API
|
||||||
|
}
|
||||||
|
|
||||||
type PublicRouter struct {
|
type PublicRouter struct {
|
||||||
|
factors twofactor.API
|
||||||
flow *flow.Service
|
flow *flow.Service
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
db account.DB
|
db account.DB
|
||||||
@@ -39,14 +45,13 @@ func NewRouter(
|
|||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
apiEndpoint string,
|
apiEndpoint string,
|
||||||
db account.DB,
|
db account.DB,
|
||||||
vdb verification.DB,
|
|
||||||
rtdb refreshtokens.DB,
|
rtdb refreshtokens.DB,
|
||||||
attempts ratelimits.Store,
|
attempts ratelimits.Store,
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
config *middleware.TokenConfig,
|
config *middleware.TokenConfig,
|
||||||
signature *middleware.Signature,
|
signature *middleware.Signature,
|
||||||
limits middleware.LoginRateLimits,
|
limits middleware.LoginRateLimits,
|
||||||
flows ...*flow.Service,
|
flows ...Flows,
|
||||||
) *PublicRouter {
|
) *PublicRouter {
|
||||||
l := logger.Named("public")
|
l := logger.Named("public")
|
||||||
hr := PublicRouter{
|
hr := PublicRouter{
|
||||||
@@ -62,11 +67,16 @@ func NewRouter(
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(flows) > 0 && flows[0] != nil {
|
if len(flows) > 0 {
|
||||||
hr.flow = 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, "/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/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
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/resend", api.Post, measured("login_send", hr.resendLogin))
|
||||||
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/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"
|
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -35,12 +33,15 @@ type signupRequest struct {
|
|||||||
Key string `json:"idempotencyKey"`
|
Key string `json:"idempotencyKey"`
|
||||||
}
|
}
|
||||||
type challengeRequest struct {
|
type challengeRequest struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
PendingToken string `json:"pendingToken"`
|
PendingToken string `json:"pendingToken"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Key string `json:"idempotencyKey"`
|
Key string `json:"idempotencyKey"`
|
||||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||||
}
|
}
|
||||||
type challengeResponse struct {
|
type challengeResponse struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
|
Channel factor.Channel `json:"channel"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
@@ -161,19 +162,28 @@ func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, session
|
|||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
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 randomKey() string { return rand.Text() }
|
||||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc {
|
func loginScope(raw string) factor.Scope {
|
||||||
expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration)
|
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 {
|
if err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
if token.Expiration.Before(expires) {
|
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})
|
||||||
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)})
|
|
||||||
}
|
}
|
||||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||||
if len(raw) > 8192 {
|
if len(raw) > 8192 {
|
||||||
@@ -206,11 +216,14 @@ func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
|||||||
if err := decodeFlow(r, &req); err != nil {
|
if err := decodeFlow(r, &req); err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
|
if req.ChallengeID == "" {
|
||||||
|
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
|
||||||
|
}
|
||||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return pr.flowError(err)
|
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 {
|
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||||
var req challengeRequest
|
var req challengeRequest
|
||||||
@@ -228,13 +241,19 @@ func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
|||||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
code := strings.TrimSpace(req.Code)
|
if pr.factors == nil {
|
||||||
if len(code) != 6 {
|
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||||
return pr.flowError(verification.ErorrTokenNotFound())
|
|
||||||
}
|
}
|
||||||
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)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex()))
|
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
||||||
return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a)
|
return result
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
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"
|
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
@@ -63,8 +66,12 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, service.Validate())
|
require.NoError(t, service.Validate())
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
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()
|
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 {
|
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||||
raw, err := json.Marshal(body)
|
raw, err := json.Marshal(body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -113,14 +120,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
if code == wrong {
|
if code == wrong {
|
||||||
wrong = "111111"
|
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.Empty(t, sessions.tokens)
|
||||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "idempotencyKey": "resend-too-soon"}).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, "code": 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.Equal(t, 200, w.Code, w.Body.String())
|
||||||
require.Len(t, sessions.tokens, 1)
|
require.Len(t, sessions.tokens, 1)
|
||||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
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)
|
require.Len(t, sessions.tokens, 1)
|
||||||
// A queue outage cannot report a successful login challenge or create a session.
|
// A queue outage cannot report a successful login challenge or create a session.
|
||||||
mail.fail = true
|
mail.fail = true
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ const (
|
|||||||
PurposePasswordReset VerificationPurpose = "password_reset"
|
PurposePasswordReset VerificationPurpose = "password_reset"
|
||||||
PurposeMagicLogin VerificationPurpose = "magic_login"
|
PurposeMagicLogin VerificationPurpose = "magic_login"
|
||||||
PurposeLogin VerificationPurpose = "login"
|
PurposeLogin VerificationPurpose = "login"
|
||||||
|
PurposeFactorEnrollment VerificationPurpose = "factor_enrollment"
|
||||||
|
PurposeFactorPreference VerificationPurpose = "factor_preference"
|
||||||
|
PurposeFactorProof VerificationPurpose = "factor_proof"
|
||||||
PurposePayout VerificationPurpose = "payout"
|
PurposePayout VerificationPurpose = "payout"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +39,12 @@ func VPFromString(s string) (VerificationPurpose, error) {
|
|||||||
return PurposeMagicLogin, nil
|
return PurposeMagicLogin, nil
|
||||||
case string(PurposeLogin):
|
case string(PurposeLogin):
|
||||||
return PurposeLogin, nil
|
return PurposeLogin, nil
|
||||||
|
case string(PurposeFactorEnrollment):
|
||||||
|
return PurposeFactorEnrollment, nil
|
||||||
|
case string(PurposeFactorPreference):
|
||||||
|
return PurposeFactorPreference, nil
|
||||||
|
case string(PurposeFactorProof):
|
||||||
|
return PurposeFactorProof, nil
|
||||||
case string(PurposePayout):
|
case string(PurposePayout):
|
||||||
return PurposePayout, nil
|
return PurposePayout, nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Second-factor service
|
||||||
|
|
||||||
|
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40),
|
||||||
|
[PR #41](https://git.sendico.io/tech/sxchnge/pulls/41), 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`.
|
||||||
+12
-10
@@ -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`.
|
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.
|
Primary module: auth; secondary modules: notification templates and Flutter auth.
|
||||||
This change stacks on notifications PR #37, which stacks on BFF PR #35.
|
The initial integration is merged. Task #40 separates login verification into
|
||||||
Merge #35, then #37, then this contribution, retargeting each dependent PR to main.
|
the [second-factor service](architecture/two-factor.md).
|
||||||
|
|
||||||
## Configuration
|
## 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.
|
`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
|
Russian for `ru` variants and English otherwise. Publish subsequent template
|
||||||
changes under a new revision rather than mutating deployed content.
|
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 `/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/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 `/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` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
||||||
| POST `/login/resend` | `pendingToken`, `idempotencyKey` | 202 with the challenge timing and same pending credential |
|
| POST `/login/resend` | `pendingToken`, `challengeId`, `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/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
|
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.
|
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.
|
Login codes expire within 10 minutes and cannot outlive their pending JWT.
|
||||||
Resending uses a one-minute cooldown, invalidates the previous code in that
|
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
|
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
|
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.
|
(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
|
operations. Tokens are stored before the RPC, so a process crash between those
|
||||||
steps requires user recovery; there is no product-side delivery outbox.
|
steps requires user recovery; there is no product-side delivery outbox.
|
||||||
|
|
||||||
Code consumption precedes session creation. If session creation or its HTTP
|
Code verification, one-use proof consumption and session creation now commit in
|
||||||
response fails, restart password login; the code is not reusable. The adapter
|
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
|
retains a confirmed session in memory and has no refresh/restore-on-reload flow
|
||||||
in this contribution. Backend authorization remains authoritative for future
|
in this contribution. Backend authorization remains authoritative for future
|
||||||
protected APIs. Notification provider failures after queue acceptance are visible
|
protected APIs. Notification provider failures after queue acceptance are visible
|
||||||
|
|||||||
@@ -536,3 +536,50 @@ 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.
|
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.
|
||||||
|
|
||||||
|
Delivered in [PR #41](https://git.sendico.io/tech/sxchnge/pulls/41) against `main`.
|
||||||
|
Branch `twofactor-40` is pushed. The PR is left open for review; no merge was performed.
|
||||||
@@ -13,6 +13,8 @@ class BackendDTO {
|
|||||||
'';
|
'';
|
||||||
String get pendingToken =>
|
String get pendingToken =>
|
||||||
(data['pendingToken'] as Map<String, dynamic>)['token'] as String;
|
(data['pendingToken'] as Map<String, dynamic>)['token'] as String;
|
||||||
|
String get challengeId => data['challengeId'] as String? ?? '';
|
||||||
|
String get channel => data['channel'] as String? ?? '';
|
||||||
String get target => data['target'] as String;
|
String get target => data['target'] as String;
|
||||||
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
||||||
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
|
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
|
||||||
|
|||||||
@@ -9,15 +9,20 @@ abstract final class BackendMapper {
|
|||||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
static FactorChallenge challenge(BackendDTO dto, String id) =>
|
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||||
FactorChallenge(
|
if (dto.challengeId.isEmpty || dto.channel != 'email') {
|
||||||
id: id,
|
throw const FactorException(FactorError.unavailable);
|
||||||
purpose: FactorPurpose.login,
|
}
|
||||||
operation: id,
|
return FactorChallenge(
|
||||||
target: dto.target,
|
id: id,
|
||||||
expiresAt: dto.expiresAt,
|
purpose: FactorPurpose.login,
|
||||||
resendAt: dto.resendAt,
|
operation: id,
|
||||||
);
|
target: dto.target,
|
||||||
|
expiresAt: dto.expiresAt,
|
||||||
|
resendAt: dto.resendAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static AuthSession session(BackendDTO dto) {
|
static AuthSession session(BackendDTO dto) {
|
||||||
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
|
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
|
||||||
throw const FormatException('Missing session');
|
throw const FormatException('Missing session');
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class BackendAuthServices
|
|||||||
final String clientId;
|
final String clientId;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String _deviceId = _key();
|
final String _deviceId = _key();
|
||||||
final _pending = <String, String>{};
|
final _pending = <String, ({String token, String challengeId})>{};
|
||||||
final _grants = <String, AuthSession>{};
|
final _grants = <String, AuthSession>{};
|
||||||
final _retryKeys = <String, String>{};
|
final _retryKeys = <String, String>{};
|
||||||
String? _queuedSignup;
|
String? _queuedSignup;
|
||||||
@@ -125,7 +125,7 @@ class BackendAuthServices
|
|||||||
});
|
});
|
||||||
final id = _key();
|
final id = _key();
|
||||||
final challenge = BackendMapper.challenge(dto, id);
|
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);
|
return AuthResult(userId: '', isSimulated: false, challenge: challenge);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +185,16 @@ class BackendAuthServices
|
|||||||
if (pending == null) throw const FactorException(FactorError.expired);
|
if (pending == null) throw const FactorException(FactorError.expired);
|
||||||
final key = _retryKeys.putIfAbsent('login:$challengeId', _key);
|
final key = _retryKeys.putIfAbsent('login:$challengeId', _key);
|
||||||
final dto = await _post('login/resend', {
|
final dto = await _post('login/resend', {
|
||||||
'pendingToken': pending,
|
'pendingToken': pending.token,
|
||||||
|
'challengeId': pending.challengeId,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
}, factor: true);
|
}, factor: true);
|
||||||
|
final challenge = BackendMapper.challenge(dto, challengeId);
|
||||||
|
if (dto.challengeId != pending.challengeId) {
|
||||||
|
throw const FactorException(FactorError.unavailable);
|
||||||
|
}
|
||||||
_retryKeys.remove('login:$challengeId');
|
_retryKeys.remove('login:$challengeId');
|
||||||
return BackendMapper.challenge(dto, challengeId);
|
return challenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -197,7 +202,8 @@ class BackendAuthServices
|
|||||||
final pending = _pending[challengeId];
|
final pending = _pending[challengeId];
|
||||||
if (pending == null) throw const FactorException(FactorError.expired);
|
if (pending == null) throw const FactorException(FactorError.expired);
|
||||||
final dto = await _post('login/verify', {
|
final dto = await _post('login/verify', {
|
||||||
'pendingToken': pending,
|
'pendingToken': pending.token,
|
||||||
|
'challengeId': pending.challengeId,
|
||||||
'code': code,
|
'code': code,
|
||||||
}, factor: true);
|
}, factor: true);
|
||||||
final session = BackendMapper.session(dto);
|
final session = BackendMapper.session(dto);
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ void main() {
|
|||||||
return response({
|
return response({
|
||||||
'userId': 'user',
|
'userId': 'user',
|
||||||
'pendingToken': {'token': 'signed-pending'},
|
'pendingToken': {'token': 'signed-pending'},
|
||||||
|
'challengeId': 'server-challenge',
|
||||||
|
'channel': 'email',
|
||||||
'target': 'u***@example.com',
|
'target': 'u***@example.com',
|
||||||
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
||||||
'resendAt': now.add(const Duration(minutes: 1)).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(request.url.path, '/api/v1/accounts/login/verify');
|
||||||
expect(body['pendingToken'], 'signed-pending');
|
expect(body['pendingToken'], 'signed-pending');
|
||||||
|
expect(body['challengeId'], 'server-challenge');
|
||||||
if (body['code'] != '123456')
|
if (body['code'] != '123456')
|
||||||
return response({'error': 'invalid'}, 400);
|
return response({'error': 'invalid'}, 400);
|
||||||
verified = true;
|
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 = <String>[];
|
||||||
|
Map<String, Object?> 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<FactorException>()));
|
||||||
|
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<FactorException>()),
|
||||||
|
);
|
||||||
|
expect(service.session, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user