Separate second-factor verification and delivery from authentication #41

Merged
tech merged 2 commits from twofactor-40 into main 2026-09-12 18:38:01 +00:00
33 changed files with 1761 additions and 112 deletions
Showing only changes of commit ecb63b61c6 - Show all commits

No files matched your search

+8
View File
@@ -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>"
}
}
} }
] ]
+24 -2
View File
@@ -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)
}
}
+13 -46
View File
@@ -1,4 +1,4 @@
// Package flow owns signup and email challenge workflows. // Package flow owns signup and account activation, independently of second-factor checks.
package flow 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}}
if purpose == verificationmodel.PurposeAccountActivation {
u, _ := url.Parse(s.ActivationURL) u, _ := url.Parse(s.ActivationURL)
q := u.Query() q := u.Query()
q.Set("token", result.VerificationToken) q.Set("token", result.VerificationToken)
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
message.Template = "auth.activation" 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()}})
message.Parameters = map[string]string{"url": u.String()} return result.ExpiresAt, err
}
// 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 {
+7 -8
View File
@@ -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)
} }
+2 -5
View File
@@ -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)
+1 -1
View File
@@ -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(),
+11 -3
View File
@@ -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
} }
+15 -5
View File
@@ -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
+16
View File
@@ -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
}
+138
View File
@@ -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})
}
+188
View File
@@ -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")
})
}
}
+83
View File
@@ -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
}
+156
View File
@@ -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)
}
+153
View File
@@ -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
}
+159
View File
@@ -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
}
+235
View File
@@ -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)
})
}
+53
View File
@@ -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:
+155
View File
@@ -0,0 +1,155 @@
# Second-factor service
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`.
## Ownership
`backend/product/twofactor` is a domain service inside the product executable.
It owns verified factors, preferred delivery channels, challenge snapshots,
issuance limits, code verification and single-use authorization proofs.
Its domain has no dependency on auth HTTP, JWTs or sessions. Shared Mongo
wrappers supply persistence and transactions; notifications only deliver messages.
Auth verifies the password, account and client, creates a pending JWT bound to the
client/device, and passes its hash as the login operation context. The service
selects the account's verified preferred factor and returns a masked challenge.
Auth calls `Complete` with the code and a callback that creates the session.
The service verifies the code, issues and consumes a proof, and commits the
callback's database changes in one transaction. Rejected guesses commit attempt
counters; callback failures roll back the code, proof and session together.
The HTTP response is written only after commit.
`Verify` and `Consume` also support separate domain steps for an operation that
needs a short-lived proof. Proofs bind the customer, purpose, operation and
challenge; they expire within two minutes and never outlive the challenge.
`Consume` checks the current profile and consumes the proof with the owner's
callback. Callbacks must only use this shared Mongo transaction: no external I/O,
response writes or irreversible side effects. Mongo may retry the callback.
An eventual remote service would need an explicit distributed completion protocol;
the callback contract cannot simply be translated into an RPC.
Signup email activation remains in `auth/flow`, with separate purpose and link
semantics. API-client secret login retains its existing separate behavior.
No generic public endpoint can choose an arbitrary authorization purpose.
## Sendico reuse
The token engine is the existing locally owned copy in
`backend/product/verification/store`. Its source is Sendico
`S_I/sendico` commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`, already recorded
in `backend/PROVENANCE.json` and `backend/PROVENANCE.md`. The new service uses
`NewOTPRequest`, `NewLinkRequest`, `CreateWithResult`, `Consume`, context binding,
idempotency, cooldown, max retries and issuance limits from that engine. It
retains SXCHNGE's subsequent random-key, empty-target-query and safe-log fixes.
No alternative OTP generator or token-consumption database was introduced.
The committed Sendico handlers inspected under
`api/edge/product/internal/server/verificationimp/` include `request.go`,
`verify.go`, `sendcode.go`, `target.go`, `service.go`, `types.go` and `token.go`.
They mix verification with session creation and private platform messaging and
allow recipient resolution unsuitable for a verified-factor boundary. Those
handlers were not copied. The orchestration, profile store, delivery interface
and HTTP settings adapter are new local code. There is no Sendico runtime,
private messaging or database dependency.
## Factors and policy
An active account's activated primary email is the initial verified factor.
Profiles are persisted in `two_factor_profiles`; challenge snapshots in
`two_factor_challenges` bind the chosen factor, locale, profile revision and
operation deadline. Snapshots cannot redirect a code during resend. Profile
changes invalidate outstanding challenges and proofs. A transactional profile
write serializes proof consumption against concurrent preference changes.
Challenge documents expire through a TTL index one day after their logical expiry;
logical expiry is checked synchronously and does not depend on TTL cleanup.
No existing customer migration is required: the initial profile is resolved
from the verified account and persisted on first completed verification.
Email is the installed channel driver. Users can enroll an additional email,
prove ownership and select it. Selection requires a fresh code at the current
preferred factor; enrollment alone cannot replace it. At most five factors
(including the primary email) are enrolled. Registered drivers form the supported
channel allow-list; unsupported or unavailable channels fail closed. There is no
automatic fallback, disable endpoint, factor removal or account recovery flow.
SMS, push, authenticator apps and passkeys require concrete implementations and
client support before use. The Flutter settings screen is still the existing
preview; these authenticated settings APIs are ready for a separate UI adapter.
Login, payout and preference scopes are recognized internally. Payout integration
is not added here; the payout owner must validate its immutable operation details
and call this service. An auth proof cannot authorize money operations.
## HTTP settings contract
All routes below are under `/api/v1/accounts` and require an existing active
account session through the standard `AccountHandler`. Pending login credentials
cannot access them. Account identity and operation scope come from the server,
not request fields. Responses use the existing `data` envelope and `no-store`.
| Method/path | Body | Result (202) |
| --- | --- | --- |
| GET `/two-factor` | none | `required: true`, `preferred`, `factors: [{id, channel, target}]` |
| POST `/two-factor/enroll` | `channel`, `address`, `idempotencyKey` | Challenge at the proposed address |
| POST `/two-factor/enroll/verify` | `challengeId`, `code` | Settings after ownership verification |
| POST `/two-factor/preference` | `factorId`, `idempotencyKey` | Challenge at the current preferred factor |
| POST `/two-factor/preference/verify` | `factorId`, `challengeId`, `code` | Settings after preference changes |
| POST `/two-factor/resend` | `challengeId`, `idempotencyKey` | Same settings operation and target, renewed code |
Challenge responses contain `challengeId`, `channel`, masked `target`, `expiresAt`
and `resendAt`. Login responses additionally include the pending JWT; login
resend/verify now require both `challengeId` and `pendingToken`. Codes, raw proofs
and full enrolled addresses are never returned in these responses.
Codes expire within ten minutes with five guesses per issued code. New issuance
uses a one-minute cooldown per challenge, plus shared fixed-window limits of five
per customer and twenty per requesting IP per hour across purposes. New challenges
cannot reset these delivery budgets. Same-key delivery retries recover the same
code, notification key and expiry; notification enqueueing deduplicates them.
A new accepted resend invalidates the prior code. A locked/expired operation must
restart. Replaying a used code or proof cannot execute the callback again.
## Rollout and recovery
Load revision 1 of `twofactor.code` alongside `auth.activation` and `auth.login`
from `backend/notifications/templates.example.json`; all have English/Russian
variants. Existing notification TLS/caller configuration and the private signing
key supply delivery and secret-derived issuance keys. No new service deployment
or network credentials are required. Mongo must support transactions (replica set).
The application creates the two new collections/index on startup.
Deploy backend and Flutter contract changes together. Earlier pending login codes
have no new challenge record and must restart password login after rollout.
Existing sessions and activation links are preserved. Key rotation invalidates
outstanding factor snapshots/proofs. Keep factors on an available driver when
changing configured drivers; there is no silent fallback.
A failed session write permits retrying the same code. A lost HTTP response after
a successful commit cannot replay the operation: restart login. Enrollment and
preference confirmation can be reconciled with GET settings after an ambiguous
response. Tokens and challenges commit before notification Send; a product crash
before Send still requires resend. No product delivery outbox or inbox receipts
are claimed. No live emails were sent during tests.
## Verification and observability
Real Mongo tests cover failed delivery replay, scope/customer isolation, expiry,
inactive accounts, attempts committed through outer transactions, proof replay,
callback rollback, concurrent completion, enrollment and preference invalidation.
The signup/login integration test exercises the new auth boundary. Transport
checks reject caller identity/purpose/destination overrides. Architecture tests
prevent the 2FA domain importing auth or sessions. Flutter adapter tests exercise
server challenge binding, resend recovery and unsupported channels.
Existing auth HTTP counters/timers and frontend journey events remain authoritative
for login outcomes. Domain logs record queued challenges, rejected codes and
completed operations with challenge IDs and purpose/channel, without codes,
proofs, keys or destinations. Prepared proof logs are debug-level and do not
represent a committed operation. See `docs/ux/auth-journeys.md` for attempt windows,
retry/abandonment rules and the existing production analytics export gap.
No new settings UI journey or production analytics sink is claimed.
The UI composition is unchanged. The shared DTO/mapper/service adapter preserves
masked delivery targets, provider error/retry handling and in-memory credential
storage. Existing widget tests cover mobile forms, keyboard behavior, loading,
cooldown and recovery; new adapter tests cover the changed backend contract.
Required completion checks and delivery are recorded in `docs/implementation.md`.
+12 -10
View File
@@ -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
+44
View File
@@ -536,3 +536,47 @@ frontend API URL/public client before enabling real auth.
Delivered in [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39). Merge order: #35 → #37 → #39. The auth branch incorporates notification completion commit `4b14649`; only the delivery-log append conflicted, and both task records are preserved. 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.
@@ -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,8 +9,11 @@ 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') {
throw const FactorException(FactorError.unavailable);
}
return FactorChallenge(
id: id, id: id,
purpose: FactorPurpose.login, purpose: FactorPurpose.login,
operation: id, operation: id,
@@ -18,6 +21,8 @@ abstract final class BackendMapper {
expiresAt: dto.expiresAt, expiresAt: dto.expiresAt,
resendAt: dto.resendAt, 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);
},
);
}
} }