Compare commits
2
Commits
bff-34
...
1a079e00cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a079e00cc | ||
|
|
ad5a061164 |
No files matched your search
@@ -5,6 +5,7 @@ test: test-backend test-frontend
|
||||
.PHONY: test-frontend
|
||||
|
||||
test-frontend:
|
||||
cd frontend/app && flutter gen-l10n
|
||||
cd frontend/packages/sxchnge_wallet && flutter test
|
||||
cd frontend/packages/sxchnge_kyc && flutter test
|
||||
cd frontend/packages/sxchnge_analytics && flutter test
|
||||
|
||||
@@ -46,7 +46,8 @@ Multiple service workers can safely compete for jobs through atomic claims.
|
||||
|
||||
## Templates and parameters
|
||||
|
||||
See `templates.example.json`. Parameters use Go template map syntax such as
|
||||
See `templates.example.json`, including the `auth.activation` and `auth.login`
|
||||
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
|
||||
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
|
||||
locale-specific content. Missing/extra parameters, unknown revisions/locales and
|
||||
invalid content are rejected before acceptance. Empty locale uses the default.
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"id": "exchange.completed",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": ["first_name", "amount", "currency"],
|
||||
"parameters": [
|
||||
"first_name",
|
||||
"amount",
|
||||
"currency"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your exchange is complete",
|
||||
@@ -11,5 +15,45 @@
|
||||
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.activation",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"url"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Verify your SXCHNGE email",
|
||||
"text": "Confirm your email: {{.url}}\nThis link expires in 24 hours. If you did not sign up, ignore this email.",
|
||||
"html": "<p>Confirm your email to finish signing up.</p><p><a href=\"{{.url}}\">Verify email</a></p><p>This link expires in 24 hours. If you did not sign up, ignore this email.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Подтвердите почту SXCHNGE",
|
||||
"text": "Подтвердите почту: {{.url}}\nСсылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.",
|
||||
"html": "<p><a href=\"{{.url}}\">Подтвердить почту</a></p><p>Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.login",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE login code",
|
||||
"text": "Your login code is {{.code}}. It expires in 10 minutes. Never share this code.",
|
||||
"html": "<p>Your login code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код входа SXCHNGE",
|
||||
"text": "Ваш код входа: {{.code}}. Он действует 10 минут. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -24,8 +24,11 @@ organization, role, policy or seed; the runtime creates its indexes.
|
||||
The runtime mounts health, login, API login, refresh, rotation and logout routes.
|
||||
Customer/application-client records must exist for login. The customer service
|
||||
supports atomic account/activation creation and deletion with session revocation.
|
||||
Signup/profile HTTP wiring and verification delivery/completion remain follow-up
|
||||
work. The frontend still uses dummy services.
|
||||
Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
Profile HTTP wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
|
||||
@@ -2,13 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
}
|
||||
|
||||
func (c *Config) SharedConfig() *Config {
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -26,11 +29,12 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
@@ -47,6 +51,9 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
if mw.notifications != nil {
|
||||
_ = mw.notifications.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
@@ -107,6 +114,7 @@ func CreateMiddleware(
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
notificationConfig ...*notifications.Config,
|
||||
) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
@@ -139,6 +147,23 @@ func CreateMiddleware(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err = authFlow.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.notifications, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow.Sender = p.notifications
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
@@ -148,6 +173,7 @@ func CreateMiddleware(
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
authFlow,
|
||||
)
|
||||
return p, nil
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func (a *APIImp) installRuntimeBasics(
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
a.config.Notifications,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,6 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
@@ -48,6 +48,8 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Package flow owns signup and email challenge workflows.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const Cooldown = time.Minute
|
||||
const LoginTTL = 10 * time.Minute
|
||||
|
||||
type Service struct {
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
Tokens verification.DB
|
||||
Transactions transaction.Factory
|
||||
Attempts ratelimits.Store
|
||||
Sender notifications.Sender
|
||||
Secret []byte
|
||||
ActivationURL string
|
||||
}
|
||||
|
||||
func (s *Service) Validate() error {
|
||||
u, err := url.Parse(s.ActivationURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
|
||||
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
|
||||
}
|
||||
if len(s.Secret) < 32 {
|
||||
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func NormalizeEmail(email string) (string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
a, err := mail.ParseAddress(email)
|
||||
if err != nil || a.Address != email || len(email) > 254 {
|
||||
return "", merrors.InvalidArgument("valid email required")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
func (s *Service) key(parts ...string) string {
|
||||
h := hmac.New(sha256.New, s.Secret)
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
||||
now := time.Now().UTC()
|
||||
start := now.Truncate(time.Hour)
|
||||
for _, scope := range []struct {
|
||||
kind, value string
|
||||
limit int
|
||||
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
|
||||
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
|
||||
if err != nil {
|
||||
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
|
||||
}
|
||||
if count > scope.limit {
|
||||
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
||||
email, err := NormalizeEmail(a.Login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Login = email
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if len(a.Name) > 200 || len(a.Password) > 72 {
|
||||
return nil, merrors.InvalidArgument("name or password too long")
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||
if lookup != nil {
|
||||
return nil, lookup
|
||||
}
|
||||
// A retry after a queue failure may resume only with the original password.
|
||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
||||
return nil, merrors.DataConflict("email already registered")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", raw)
|
||||
u.RawQuery = q.Encode()
|
||||
locale := "en"
|
||||
if strings.HasPrefix(a.Locale, "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
||||
} else {
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||
email, err := NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.Accounts.GetByEmail(ctx, email)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil
|
||||
}
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Issue(ctx context.Context, a *customer.Account, purpose verificationmodel.VerificationPurpose, scope, key string, deadlines ...time.Time) (time.Time, error) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required (maximum 128 characters)")
|
||||
}
|
||||
secretKey := s.key(a.ID.Hex(), string(purpose), scope, key)
|
||||
req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5)
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
req = verification.NewLinkRequest(a.ID, purpose, "").WithTTL(24 * time.Hour)
|
||||
}
|
||||
if len(deadlines) > 0 {
|
||||
remaining := time.Until(deadlines[0])
|
||||
if remaining <= 0 {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
if remaining < req.Ttl {
|
||||
req.WithTTL(remaining)
|
||||
}
|
||||
}
|
||||
req.WithContextRef(scope).WithIdempotencyKey(secretKey).WithCooldown(Cooldown)
|
||||
now := time.Now().UTC()
|
||||
// Destination limits span every challenge/context, including password retries.
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, now))
|
||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !result.ExpiresAt.After(now) {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
message := notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.login", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"code": result.VerificationToken}}
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", result.VerificationToken)
|
||||
u.RawQuery = q.Encode()
|
||||
message.Template = "auth.activation"
|
||||
message.Parameters = map[string]string{"url": u.String()}
|
||||
}
|
||||
// Replay recovers ambiguous RPC failures using the same rendered payload and expiry.
|
||||
if err = s.Sender.Send(ctx, message); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return result.ExpiresAt, nil
|
||||
}
|
||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||
if len(raw) != 43 {
|
||||
return verification.ErorrTokenNotFound()
|
||||
}
|
||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil, merrors.Unauthorized("account cannot be activated")
|
||||
}
|
||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
model "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenStub struct {
|
||||
verification.DB
|
||||
request *verification.Request
|
||||
result verification.CreateResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
|
||||
s.request = r
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
type senderStub struct {
|
||||
messages []notifications.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
|
||||
s.messages = append(s.messages, m)
|
||||
return s.err
|
||||
}
|
||||
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
||||
expiry := time.Now().UTC().Add(time.Minute)
|
||||
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
||||
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
|
||||
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32))}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = "user@example.com"
|
||||
a.Locale = "ru-RU"
|
||||
_, err := service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
require.Error(t, err)
|
||||
key := *tokens.request.IdempotencyKey
|
||||
require.NotEqual(t, "client-key", key)
|
||||
require.Equal(t, "pending-scope", tokens.request.ContextRef)
|
||||
require.LessOrEqual(t, tokens.request.Ttl, time.Minute)
|
||||
sender.err = nil
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.key(a.ID.Hex(), string(model.PurposeLogin), "pending-scope", "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
require.Len(t, sender.messages, 2)
|
||||
}
|
||||
func TestValidateActivationDestination(t *testing.T) {
|
||||
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.NoError(t, s.Validate())
|
||||
}
|
||||
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.Error(t, s.Validate())
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ package router
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
@@ -45,6 +45,7 @@ func NewDispatcher(
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
@@ -54,7 +55,7 @@ func NewDispatcher(
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -14,6 +12,8 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
@@ -195,7 +195,7 @@ func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.
|
||||
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
||||
}
|
||||
req.Login = strings.TrimSpace(req.Login)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
// Preserve passwords exactly as entered.
|
||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||
@@ -225,6 +225,9 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||
return h
|
||||
}
|
||||
if a.flow != nil {
|
||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||
}
|
||||
return a.respondPendingLogin(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
|
||||
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
|
||||
|
||||
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
|
||||
return func(r *http.Request) http.HandlerFunc {
|
||||
start := time.Now()
|
||||
response := handler(r)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
response(wrapped, r)
|
||||
outcome := "accepted"
|
||||
if wrapped.Status() >= 500 {
|
||||
outcome = "unavailable"
|
||||
} else if wrapped.Status() == 429 {
|
||||
outcome = "limited"
|
||||
} else if wrapped.Status() >= 400 {
|
||||
outcome = "rejected"
|
||||
}
|
||||
flowRequests.WithLabelValues(operation, outcome).Inc()
|
||||
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
type PublicRouter struct {
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
@@ -44,6 +46,7 @@ func NewRouter(
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
@@ -59,7 +62,15 @@ func NewRouter(
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||
if len(flows) > 0 && flows[0] != nil {
|
||||
hr.flow = flows[0]
|
||||
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
|
||||
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
||||
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", api.Post, measured("login", hr.login))
|
||||
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
|
||||
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
||||
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
type challengeRequest struct {
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
UserID string `json:"userId"`
|
||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func decodeFlow(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 (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
if details, ok := verification.Throttle(err); ok {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
return response.Unauthorized(pr.logger, pr.service, "credentials required")
|
||||
default:
|
||||
// Do not include downstream errors: they may contain transport/request details.
|
||||
pr.logger.Warn("Authentication flow dependency failed")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
|
||||
var req signupRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
a := &customer.Account{Password: req.Password}
|
||||
a.Name = req.Name
|
||||
a.Login = req.Email
|
||||
a.Locale = req.Locale
|
||||
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
|
||||
}
|
||||
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
|
||||
}
|
||||
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Account activation completed")
|
||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
||||
}
|
||||
func challengeScope(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
|
||||
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("session identifier required"))
|
||||
}
|
||||
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
|
||||
claims["clientId"] = sessionID.ClientID
|
||||
claims["deviceId"] = sessionID.DeviceID
|
||||
// Independent challenges, even when password requests arrive within one second.
|
||||
claims["nonce"] = randomKey()
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
_, raw, err := ja.Encode(claims)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
||||
return pr.queueLogin(r, a, token, key)
|
||||
}
|
||||
func randomKey() string { return rand.Text() }
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc {
|
||||
expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if token.Expiration.Before(expires) {
|
||||
expires = token.Expiration
|
||||
}
|
||||
pr.logger.Info("Login verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, challengeResponse{UserID: a.ID.Hex(), PendingToken: token, Target: mask.Email(a.Login), ExpiresAt: expires, ResendAt: time.Now().Add(flow.Cooldown)})
|
||||
}
|
||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||
if len(raw) > 8192 {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
t, err := jwtauth.VerifyToken(ja, raw)
|
||||
if err != nil {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
token, err := authmodel.Claims2Token(claims)
|
||||
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
|
||||
return nil, nil, merrors.Unauthorized("pending login required")
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, merrors.Unauthorized("inactive account")
|
||||
}
|
||||
return a, token, nil
|
||||
}
|
||||
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key)
|
||||
}
|
||||
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
|
||||
if h != nil {
|
||||
return h
|
||||
}
|
||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||
return h
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if len(code) != 6 {
|
||||
return pr.flowError(verification.ErorrTokenNotFound())
|
||||
}
|
||||
if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex()))
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
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/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedMail struct {
|
||||
messages []notifications.Message
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
return merrors.ServiceUnavailable("test queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 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": "auth_flow"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
|
||||
require.NoError(t, err)
|
||||
mail := &capturedMail{}
|
||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
require.NoError(t, service.Validate())
|
||||
// Keep the real verification/customer stores and capture only session writes.
|
||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||
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)
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
routes.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||
w := post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 1)
|
||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||
require.Equal(t, "ru", mail.messages[0].Locale)
|
||||
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
|
||||
require.NoError(t, err)
|
||||
activation := activationURL.Query().Get("token")
|
||||
require.NotEmpty(t, activation)
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||
require.Equal(t, 403, post("login", login).Code)
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||
w = post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, customer.AccountActive, a.Status)
|
||||
w = post("login", login)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 2)
|
||||
require.Empty(t, sessions.tokens)
|
||||
var pending struct {
|
||||
Data challengeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
|
||||
require.NotEmpty(t, pending.Data.PendingToken.Token)
|
||||
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
|
||||
code := mail.messages[1].Parameters["code"]
|
||||
require.Len(t, code, 6)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code})
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
require.Equal(t, 503, post("login", login).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
}
|
||||
@@ -31,3 +31,9 @@ api:
|
||||
token_length: 32
|
||||
check:
|
||||
min_length: 12
|
||||
notifications:
|
||||
address: localhost:8443
|
||||
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
|
||||
ca_file: ../notifications/tls/ca.pem
|
||||
server_name: localhost
|
||||
activation_url: http://localhost:5126/verify-email
|
||||
@@ -69,13 +69,17 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
|
||||
require (
|
||||
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
|
||||
git.sendico.io/tech/sxchnge/shared v0.0.0
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -89,8 +93,10 @@ require (
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
moul.io/chizap v1.0.3
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
replace git.sendico.io/tech/sxchnge/notifications/interface => ../notifications/interface
|
||||
+12
-2
@@ -63,6 +63,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -226,6 +228,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -267,8 +271,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package notifications adapts the internal notification wire contract to product models.
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string `yaml:"address"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
CAFile string `yaml:"ca_file"`
|
||||
ServerName string `yaml:"server_name"`
|
||||
ActivationURL string `yaml:"activation_url"`
|
||||
}
|
||||
type Message struct {
|
||||
Key, UserID, Email, Locale, Template string
|
||||
Parameters map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Sender interface {
|
||||
Send(context.Context, Message) error
|
||||
}
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
rpc pb.NotificationsServiceClient
|
||||
token string
|
||||
}
|
||||
|
||||
func Open(c Config) (*Client, error) {
|
||||
token := os.Getenv(c.TokenEnv)
|
||||
if c.Address == "" || len(token) < 32 {
|
||||
return nil, merrors.InvalidArgument("notification address and caller token of at least 32 bytes are required")
|
||||
}
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName}
|
||||
if c.CAFile != "" {
|
||||
pem, err := os.ReadFile(c.CAFile)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "read notification CA")
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, merrors.InvalidArgument("invalid notification CA")
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create notification client")
|
||||
}
|
||||
return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn), token: token}, nil
|
||||
}
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
func (c *Client) Send(ctx context.Context, m Message) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
|
||||
res, err := c.rpc.Send(ctx, request(m))
|
||||
if err != nil {
|
||||
return merrors.ServiceUnavailableWrap(err, "notification queue unavailable")
|
||||
}
|
||||
if res.GetNotification().GetId() == "" || len(res.GetNotification().GetDeliveries()) != 1 {
|
||||
return merrors.ServiceUnavailable("notification queue returned no receipt")
|
||||
}
|
||||
for _, d := range res.Notification.Deliveries {
|
||||
switch d.State {
|
||||
case pb.DeliveryState_DELIVERY_STATE_FAILED, pb.DeliveryState_DELIVERY_STATE_EXPIRED, pb.DeliveryState_DELIVERY_STATE_UNKNOWN:
|
||||
return merrors.ServiceUnavailable("notification could not be delivered")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func request(m Message) *pb.SendRequest {
|
||||
return &pb.SendRequest{IdempotencyKey: m.Key, UserId: m.UserID, TemplateId: m.Template, TemplateVersion: "1", Locale: m.Locale, Parameters: m.Parameters, ExpiresAt: timestamppb.New(m.ExpiresAt), Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: m.Email}}}}}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/pem"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type rpcStub struct {
|
||||
pb.NotificationsServiceClient
|
||||
request *pb.SendRequest
|
||||
auth string
|
||||
result *pb.SendResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) {
|
||||
s.request = r
|
||||
md, _ := metadata.FromOutgoingContext(ctx)
|
||||
s.auth = md.Get("authorization")[0]
|
||||
return s.result, s.err
|
||||
}
|
||||
func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
|
||||
rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}}
|
||||
client := &Client{rpc: rpc, token: "service-secret"}
|
||||
expiry := time.Now().Add(time.Minute)
|
||||
m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry}
|
||||
require.NoError(t, client.Send(context.Background(), m))
|
||||
require.Equal(t, "Bearer service-secret", rpc.auth)
|
||||
require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address)
|
||||
require.Equal(t, "1", rpc.request.TemplateVersion)
|
||||
require.Equal(t, m.Parameters, rpc.request.Parameters)
|
||||
require.WithinDuration(t, expiry, rpc.request.ExpiresAt.AsTime(), time.Nanosecond)
|
||||
rpc.result.Notification.Deliveries[0].State = pb.DeliveryState_DELIVERY_STATE_UNKNOWN
|
||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
||||
rpc.result = nil
|
||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
||||
}
|
||||
func TestOpenRequiresCredentialsAndValidCA(t *testing.T) {
|
||||
_, err := Open(Config{Address: "localhost:8443", TokenEnv: "ABSENT_NOTIFICATION_TEST_TOKEN"})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
t.Setenv("TEST_NOTIFICATION_TOKEN", "test-token-that-is-at-least-32-bytes")
|
||||
_, err = Open(Config{Address: "localhost:8443", TokenEnv: "TEST_NOTIFICATION_TOKEN", CAFile: "missing.pem"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
type tlsRPC struct {
|
||||
pb.UnimplementedNotificationsServiceServer
|
||||
auth chan string
|
||||
}
|
||||
|
||||
func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) {
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
s.auth <- md.Get("authorization")[0]
|
||||
return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil
|
||||
}
|
||||
func TestOpenUsesVerifiedTLSAndCallerAuthentication(t *testing.T) {
|
||||
fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
cert := fixture.TLS.Certificates[0]
|
||||
fixture.Close()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})))
|
||||
rpc := &tlsRPC{auth: make(chan string, 1)}
|
||||
pb.RegisterNotificationsServiceServer(server, rpc)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
ca := filepath.Join(t.TempDir(), "ca.pem")
|
||||
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600))
|
||||
t.Setenv("TEST_TLS_CALLER_TOKEN", "test-service-token-with-at-least-32-bytes")
|
||||
client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_TLS_CALLER_TOKEN", CAFile: ca})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
|
||||
require.Equal(t, "Bearer test-service-token-with-at-least-32-bytes", <-rpc.auth)
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const hashPreviewChars = 12
|
||||
|
||||
func hashPreview(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= hashPreviewChars {
|
||||
return value
|
||||
}
|
||||
return value[:hashPreviewChars] + "..."
|
||||
}
|
||||
|
||||
func tokenIsDigitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
@@ -38,7 +27,6 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.String("token_purpose", string(token.Purpose)),
|
||||
zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""),
|
||||
zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil),
|
||||
zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.Bool("token_has_salt", token.Salt != nil),
|
||||
zap.Bool("token_used", token.UsedAt != nil),
|
||||
zap.Time("token_expires_at", token.ExpiresAt),
|
||||
@@ -46,18 +34,12 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.Bool("token_has_max_retries", token.MaxRetries != nil),
|
||||
}
|
||||
|
||||
if token.Salt != nil {
|
||||
fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt)))
|
||||
}
|
||||
if token.UsedAt != nil {
|
||||
fields = append(fields, zap.Time("token_used_at", *token.UsedAt))
|
||||
}
|
||||
if token.MaxRetries != nil {
|
||||
fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries))
|
||||
}
|
||||
if token.IdempotencyKey != nil {
|
||||
fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey)))
|
||||
}
|
||||
if strings.TrimSpace(token.ContextRef) != "" {
|
||||
fields = append(fields, zap.String("token_context_ref", token.ContextRef))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
@@ -132,8 +132,6 @@ func (db *verificationDB) lookupConsumeOTPToken(
|
||||
zap.Time("candidate_expires_at", token.ExpiresAt),
|
||||
zap.Int("candidate_attempts", token.Attempts),
|
||||
zap.Bool("candidate_has_max_retries", token.MaxRetries != nil),
|
||||
zap.String("candidate_stored_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.String("candidate_computed_hash_prefix", hashPreview(hash)),
|
||||
zap.Bool("candidate_match", match),
|
||||
)
|
||||
if match {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -72,7 +72,6 @@ func (scope consumeScope) startFields() []zap.Field {
|
||||
zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)),
|
||||
zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)),
|
||||
zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken),
|
||||
zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -21,7 +21,7 @@ func activeContextFilter(request *Request, now time.Time) builder.Query {
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func cooldownActiveContextFilter(request *Request, now, cutoff time.Time) builde
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
|
||||
@@ -140,7 +140,10 @@ func (db *verificationDB) CreateWithResult(
|
||||
// Always store an idempotency key. Client keys enable replay; generated
|
||||
// keys avoid null-key collisions on older Mongo indexes and keep
|
||||
// transaction retries deterministic.
|
||||
idempotencyKey = syntheticIdempotencyKey()
|
||||
idempotencyKey, err = syntheticIdempotencyKey()
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
hasIdempotency = true
|
||||
}
|
||||
|
||||
@@ -246,3 +249,12 @@ func (db *verificationDB) CreateWithResult(
|
||||
createResult.VerificationToken = raw
|
||||
return createResult, nil
|
||||
}
|
||||
|
||||
// Older records omit an empty target; Mongo does not equate a missing field
|
||||
// with the empty string. Match both forms for cooldown, invalidation and replay.
|
||||
func targetFilter(target string) builder.Query {
|
||||
if target != "" {
|
||||
return repository.Filter("target", target)
|
||||
}
|
||||
return repository.Query().Or(repository.Filter("target", ""), repository.Filter("target", nil))
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
@@ -24,8 +25,12 @@ func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
return key, true
|
||||
}
|
||||
|
||||
func syntheticIdempotencyKey() string {
|
||||
return "auto:" + bson.NewObjectID().Hex()
|
||||
func syntheticIdempotencyKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", merrors.InternalWrap(err, "generate verification key")
|
||||
}
|
||||
return "auto:" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func idempotencyContextFilter(
|
||||
@@ -35,7 +40,7 @@ func idempotencyContextFilter(
|
||||
return repository.Query().And(
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("idempotencyKey", idempotencyKey),
|
||||
)
|
||||
}
|
||||
@@ -75,6 +80,12 @@ func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *
|
||||
if existingContextRef != request.ContextRef {
|
||||
return nil, false, ErrorIdempotencyConflict()
|
||||
}
|
||||
if tokens[i].UsedAt != nil {
|
||||
return nil, false, ErorrTokenAlreadyUsed()
|
||||
}
|
||||
if tokens[i].MaxRetries != nil && tokens[i].Attempts >= *tokens[i].MaxRetries {
|
||||
return nil, false, ErrorTokenAttemptsExceeded()
|
||||
}
|
||||
if replayed == nil {
|
||||
token := tokens[i]
|
||||
replayed = &token
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -62,6 +62,18 @@ func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool {
|
||||
|
||||
func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool {
|
||||
switch elem.Key {
|
||||
case "$or":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range arr {
|
||||
d, ok := sub.(bson.D)
|
||||
if ok && matchBsonD(d, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "$and":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
|
||||
@@ -3,12 +3,13 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreate_CooldownBlocksCreation(t *testing.T) {
|
||||
@@ -174,3 +175,16 @@ func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) {
|
||||
}
|
||||
repo.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestCreateWithResultReplayCannotRedeliverConsumedCode(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
id := bson.NewObjectID()
|
||||
r := NewOTPRequest(id, verificationmodel.PurposeLogin, "").WithIdempotencyKey("private-server-key").WithContextRef("login")
|
||||
first, err := db.Create(ctx, r)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Consume(ctx, id, verificationmodel.PurposeLogin, "login", first)
|
||||
require.NoError(t, err)
|
||||
_, err = db.CreateWithResult(ctx, r)
|
||||
require.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# Signup and login email integration
|
||||
|
||||
Task [#38](https://git.sendico.io/tech/sxchnge/issues/38), [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39), branch `auth-38`.
|
||||
Primary module: auth; secondary modules: notification templates and Flutter auth.
|
||||
This change stacks on notifications PR #37, which stacks on BFF PR #35.
|
||||
Merge #35, then #37, then this contribution, retargeting each dependent PR to main.
|
||||
|
||||
## Configuration
|
||||
|
||||
Load the `auth.activation` and `auth.login` revision 1 templates from
|
||||
`backend/notifications/templates.example.json` into the notification catalog.
|
||||
Both templates have English and Russian variants; product account locale selects
|
||||
Russian for `ru` variants and English otherwise. Publish subsequent template
|
||||
changes under a new revision rather than mutating deployed content.
|
||||
|
||||
Configure `api.notifications` in the product YAML:
|
||||
|
||||
- `address`: internal notifications gRPC address.
|
||||
- `token_env`: environment variable containing the configured caller's token
|
||||
(at least 32 bytes); example `SXCHNGE_NOTIFICATIONS_TOKEN`.
|
||||
- `ca_file`: trusted PEM CA bundle; omit to use system roots.
|
||||
- `server_name`: optional TLS certificate hostname override, never a TLS bypass.
|
||||
- `activation_url`: absolute frontend `/verify-email` URL. HTTPS is required,
|
||||
except HTTP on localhost/127.0.0.1 for development. No user-supplied redirect is
|
||||
accepted. The token is appended as a query parameter.
|
||||
|
||||
The caller token must map to the product caller in the notification service's
|
||||
configuration. Keep Mongo, notification caller, TLS and provider credentials out
|
||||
of Git. JWT signing configuration must contain at least 32 bytes of private key
|
||||
material; HMAC-derived issuance keys depend on this key. Rotation changes replay
|
||||
keys and existing pending JWT validity. Omission of `api.notifications` preserves
|
||||
the bootstrap-only legacy routes; it does not enable signup or code completion.
|
||||
The example config enables the integration and therefore requires credentials.
|
||||
|
||||
The notification service must be reachable over TLS, with its Mongo/NATS/email
|
||||
provider configured. Product startup creates a lazy gRPC channel; dependency
|
||||
outages appear as HTTP 503 when attempting delivery. A successful Send means
|
||||
persisted queue acceptance, not inbox delivery or a provider receipt.
|
||||
|
||||
Start Flutter with:
|
||||
|
||||
```sh
|
||||
flutter run --dart-define=SXCHNGE_API_URL=https://api.example/api/v1 \
|
||||
--dart-define=SXCHNGE_CLIENT_ID=your-provisioned-public-client
|
||||
```
|
||||
|
||||
Provision that client using the existing local application-client configuration
|
||||
workflow. Its ID must exist in the BFF's client store and allow the user's IP.
|
||||
Do not put a confidential client secret in Flutter. Match BFF CORS to the exact
|
||||
frontend origin. Browser requests use credentials, the existing CSRF headers,
|
||||
and Secure/HttpOnly/SameSite=Strict refresh cookies; deploy frontend and API on
|
||||
HTTPS origins within the same site. Native adapters retain credentials in memory.
|
||||
No API URL keeps the existing explicit preview services. With an API URL, auth
|
||||
is real, the preview shortcut is hidden, and workspace routes require the local
|
||||
confirmed session. Wallet, identity and factor-settings backend adapters remain
|
||||
outside this change; those screens still use their existing preview data. The
|
||||
real factor adapter refuses unsupported money/settings operations.
|
||||
|
||||
## HTTP contract
|
||||
|
||||
All paths have prefix `/api/v1/accounts`. Responses use the existing `data`
|
||||
envelope. Credentials, codes and activation tokens are sent in POST bodies.
|
||||
|
||||
| Route | Request | Successful result |
|
||||
| --- | --- | --- |
|
||||
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
|
||||
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
|
||||
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
|
||||
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, masked `target`, `expiresAt`, `resendAt` |
|
||||
| POST `/login/resend` | `pendingToken`, `idempotencyKey` | 202 with the challenge timing and same pending credential |
|
||||
| POST `/login/verify` | `pendingToken`, six-digit `code` | 200 with account and existing access/refresh response |
|
||||
|
||||
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.
|
||||
The API-login client-secret endpoint retains its pre-existing separate behavior.
|
||||
Unverified customers cannot log in; Flutter routes them to verification recovery.
|
||||
Passwords preserve whitespace, matching signup hashing. Activation is single-use
|
||||
and commits the account transition with token consumption in one transaction.
|
||||
|
||||
Login codes expire within 10 minutes and cannot outlive their pending JWT.
|
||||
Resending uses a one-minute cooldown, invalidates the previous code in that
|
||||
challenge, and allows five deliveries per destination per hour. Incorrect codes
|
||||
consume the existing five-attempt budget. Restart login after a pending credential
|
||||
expires or a challenge locks. Signup/resend request limits also apply per email
|
||||
(10/hour) and IP (30/hour), using shared atomic fixed-window counters.
|
||||
|
||||
New idempotency keys are server-secret-derived before token generation. Automatic
|
||||
verification keys now use 32 cryptographically random bytes. Empty-target queries
|
||||
match both omitted and empty-string stored fields, fixing Mongo cooldown,
|
||||
invalidation and replay semantics without rewriting existing documents.
|
||||
Consumed or locked records cannot be replayed to redeliver an unusable code.
|
||||
Token/hash/salt/key previews are excluded from verification logs.
|
||||
|
||||
## Recovery and delivery limits
|
||||
|
||||
A resend retries its same idempotency key after a failed/ambiguous RPC. Token
|
||||
replay reconstructs the same code/link and stored expiry, and notification Send
|
||||
deduplicates the same payload. A new accepted resend gets a new key. A failed
|
||||
initial signup leaves the account pending; retry the same credentials or request
|
||||
another activation email after cooldown. Initial signup and password-login
|
||||
retries can create replacement messages; they are not distributed exactly-once
|
||||
operations. Tokens are stored before the RPC, so a process crash between those
|
||||
steps requires user recovery; there is no product-side delivery outbox.
|
||||
|
||||
Code consumption precedes session creation. If session creation or its HTTP
|
||||
response fails, restart password login; the code is not reusable. The adapter
|
||||
retains a confirmed session in memory and has no refresh/restore-on-reload flow
|
||||
in this contribution. Backend authorization remains authoritative for future
|
||||
protected APIs. Notification provider failures after queue acceptance are visible
|
||||
in the notification service; no inbox/delivery-receipt status is claimed here.
|
||||
No live emails were sent during implementation tests.
|
||||
|
||||
## Measurement and UX review
|
||||
|
||||
Existing `InstrumentedAuthServices`, `JourneyRecorder`, and factor providers track
|
||||
signup, initial email acceptance, confirmation, login challenge verification,
|
||||
completion, failure, retry, and navigation abandonment. Requests retain the
|
||||
existing journey correlation and timeout rules in `docs/ux/auth-journeys.md`.
|
||||
The initial confirmation-page send consumes the signup queue receipt once to
|
||||
avoid sending a duplicate email. Success is based on backend acceptance or
|
||||
verification, with `simulated: false`; it never means an inbox receipt.
|
||||
|
||||
Backend metrics `sxchnge_auth_flow_requests_total{operation,outcome}` and
|
||||
`sxchnge_auth_flow_duration_seconds{operation}` expose accepted, rejected, limited
|
||||
and unavailable HTTP steps without email, tokens or account labels. Combine
|
||||
login-verify acceptance with login requests to inspect completion and dependency
|
||||
failures. Counts are HTTP attempts, so retries are not distinct users. The current
|
||||
frontend analytics sink is still the existing injected/default preview sink;
|
||||
a production sink and cross-device confirmation correlation remain deployment
|
||||
work. Do not interpret default in-memory analytics as retained production data.
|
||||
|
||||
UX checks cover unchanged mobile/keyboard form composition, preserved input after
|
||||
errors, loading and cooldown feedback, invalid/expired/locked code recovery,
|
||||
unverified-account recovery, and English/Russian copy. Queue feedback is phrased
|
||||
conditionally because absent/already-active emails deliberately receive the same
|
||||
acceptance response. Auth credentials are absent from provider state, analytics,
|
||||
URLs (except the existing activation link) and local persistent storage. Existing
|
||||
widget suites cover screen behavior; new adapter tests cover real response state
|
||||
mapping, duplicate initial delivery, failures and one-use completion grants.
|
||||
|
||||
## Validation
|
||||
|
||||
Required validation: `make test` (including `make test-frontend`),
|
||||
`make lint-backend`, Dart format checks for every changed Dart file,
|
||||
and `flutter analyze` in `frontend/packages/sxchnge_auth` and `frontend/app`.
|
||||
Results and the delivery PR are recorded in `docs/implementation.md` and task #38.
|
||||
@@ -497,3 +497,42 @@ in [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), which remains unmerge
|
||||
an isolated clean checkout containing this contribution. Shared-workspace runs
|
||||
initially failed because concurrent product/auth work had missing module
|
||||
dependencies and unformatted files; that work was excluded and left untouched.
|
||||
|
||||
## Signup and login notifications (#38)
|
||||
|
||||
Task [#38](https://git.sendico.io/tech/sxchnge/issues/38), branch `auth-38`.
|
||||
Added signup/activation and login-code routes using the local customer,
|
||||
verification and session stores, with a TLS-authenticated adapter to the
|
||||
notifications interface module. English/Russian email templates and Flutter
|
||||
DTO/mapper/service adapters preserve provider/UI boundaries. Backend mode hides
|
||||
the preview shortcut and requires confirmed login for workspace navigation;
|
||||
unverified accounts go to email recovery.
|
||||
|
||||
Review fixed real Mongo empty-target matching for cooldown/invalidation/replay,
|
||||
replaced predictable automatic verification keys with cryptographic randomness,
|
||||
removed token/hash/key previews from verification logging, bounded resend expiry
|
||||
by the pending JWT, preserved password whitespace and prevented consumed-code
|
||||
redelivery. Structured flow outcomes and latency metrics supplement existing
|
||||
journey instrumentation. Generated localization files are now ignored/untracked;
|
||||
`make test-frontend` regenerates them from ARB sources before tests.
|
||||
|
||||
Validation passed: `make test` (Go race tests and all frontend suites),
|
||||
`make lint-backend`, `flutter analyze` in both `frontend/packages/sxchnge_auth`
|
||||
and `frontend/app`, and Dart format verification for all 17 changed hand-written
|
||||
Dart files. Tests include a real Mongo signup/activation/login journey, caller
|
||||
TLS authentication, unavailable delivery, invalid/single-use codes, immutable
|
||||
resend payloads, Flutter adapter/session mapping, unverified-account recovery,
|
||||
and backend-mode route gating. Initial failures in query-test OR support,
|
||||
localized-copy expectations and formatting were fixed and the checks rerun.
|
||||
No live email or optional security scan was run.
|
||||
|
||||
See [configuration, contracts, UX evidence and recovery limits](auth-notifications.md).
|
||||
Queue acceptance is not inbox delivery. Product-side crashes before Send require
|
||||
user resend; initial signup/login retries are not exactly-once operations.
|
||||
Session response failures require restarting login. Session restore/refresh,
|
||||
production analytics export and wallet/settings backend adapters remain outside
|
||||
this contribution. Configure notification credentials/TLS/templates and the
|
||||
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.
|
||||
@@ -46,3 +46,6 @@ app.*.map.json
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
||||
# Localization bindings are regenerated from ARB sources.
|
||||
/lib/generated/i18n/
|
||||
@@ -32,7 +32,7 @@ From the repository root, `make test-frontend` runs both theme suites and the ap
|
||||
|
||||
## English and Russian
|
||||
|
||||
Localization follows Sendico's Flutter pattern: `lib/l10n/en.arb` is the template, `ru.arb` contains Russian translations, and `flutter gen-l10n` writes `lib/generated/i18n/`. Edit ARB files, not generated Dart files. Flutter's Material and Cupertino localizations are included through the generated delegates.
|
||||
Localization follows Sendico's Flutter pattern: `lib/l10n/en.arb` is the template, `ru.arb` contains Russian translations, and `flutter gen-l10n` writes `lib/generated/i18n/`. Generated Dart files are ignored and untracked. Run `flutter gen-l10n` after a clean checkout and after editing ARB files; `make test-frontend` runs it before tests. Edit ARB files, not generated Dart files. Flutter's Material and Cupertino localizations are included through the generated delegates.
|
||||
|
||||
The language dropdown is available in both authentication modes. Startup reads `sxchnge_last_locale` using shared preferences; without a saved choice it uses the device language. Russian regional variants resolve to Russian and unsupported languages fall back to English. Preference writes are serialized, and storage failures leave the selected language usable for the current session with a localized notice. Only the language preference is stored; credentials remain transient.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class AuthFeedback extends StatelessWidget {
|
||||
? (signUp ? l.signupPreview : l.loginPreview)
|
||||
: l.authCompleted)
|
||||
: switch (error) {
|
||||
AuthError.accountNotVerified => l.authVerifyEmail,
|
||||
AuthError.invalidCredentials => l.authInvalidCredentials,
|
||||
AuthError.emailInUse => l.authEmailInUse,
|
||||
AuthError.unavailable => l.authUnavailable,
|
||||
|
||||
@@ -104,6 +104,11 @@ class _AuthFormState extends State<AuthForm> {
|
||||
locale: locale,
|
||||
);
|
||||
if (!mounted || _signUp) return;
|
||||
if (login.state.error == AuthError.accountNotVerified) {
|
||||
_password.clear();
|
||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||
return;
|
||||
}
|
||||
final challenge = login.state.result?.challenge;
|
||||
if (challenge != null) {
|
||||
_password.clear();
|
||||
@@ -329,10 +334,11 @@ class _AuthFormState extends State<AuthForm> {
|
||||
AuthFeedback(state: state, signUp: _signUp),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/accounts'),
|
||||
child: Text(l.wEnter),
|
||||
),
|
||||
if (context.read<TwoFactorService>().isSimulated)
|
||||
TextButton(
|
||||
onPressed: () => context.go('/accounts'),
|
||||
child: Text(l.wEnter),
|
||||
),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,860 +0,0 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'SXCHNGE · Your next move';
|
||||
|
||||
@override
|
||||
String get nextChapter => 'YOUR NEXT CHAPTER';
|
||||
|
||||
@override
|
||||
String get loginHeading => 'Hey, welcome\nback.';
|
||||
|
||||
@override
|
||||
String get signupHeading => 'Make your\nfirst move.';
|
||||
|
||||
@override
|
||||
String get loginIntro => 'Your next move is just a login away.';
|
||||
|
||||
@override
|
||||
String get signupIntro => 'A new account. A world of possibility.';
|
||||
|
||||
@override
|
||||
String get login => 'Log in';
|
||||
|
||||
@override
|
||||
String get signup => 'Sign up';
|
||||
|
||||
@override
|
||||
String get name => 'Your name';
|
||||
|
||||
@override
|
||||
String get email => 'Email address';
|
||||
|
||||
@override
|
||||
String get emailHint => 'you@example.com';
|
||||
|
||||
@override
|
||||
String get password => 'Password';
|
||||
|
||||
@override
|
||||
String get confirmPassword => 'Confirm password';
|
||||
|
||||
@override
|
||||
String get passwordHint => 'Use at least 12 characters.';
|
||||
|
||||
@override
|
||||
String get showPassword => 'Show password';
|
||||
|
||||
@override
|
||||
String get hidePassword => 'Hide password';
|
||||
|
||||
@override
|
||||
String get showConfirmation => 'Show confirmation';
|
||||
|
||||
@override
|
||||
String get hideConfirmation => 'Hide confirmation';
|
||||
|
||||
@override
|
||||
String get createAccount => 'Create account';
|
||||
|
||||
@override
|
||||
String get letsGo => 'Let’s go';
|
||||
|
||||
@override
|
||||
String get loginPreview => 'You’re signed in.';
|
||||
|
||||
@override
|
||||
String get signupPreview => 'Your account is ready.';
|
||||
|
||||
@override
|
||||
String get alreadyMember => 'Already part of sxchnge?';
|
||||
|
||||
@override
|
||||
String get newHere => 'New around here?';
|
||||
|
||||
@override
|
||||
String get loginInstead => 'Log in instead';
|
||||
|
||||
@override
|
||||
String get createAnAccount => 'Create an account';
|
||||
|
||||
@override
|
||||
String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.';
|
||||
|
||||
@override
|
||||
String get brandHeading => 'Good moves.\nStart here.';
|
||||
|
||||
@override
|
||||
String get brandIntro => 'A fresh way forward.\nMake it yours with sxchnge.';
|
||||
|
||||
@override
|
||||
String get brandCard => 'Less friction.\nMore possibility.';
|
||||
|
||||
@override
|
||||
String get brandFooter => 'YOUR NEXT MOVE STARTS WITH YOU.';
|
||||
|
||||
@override
|
||||
String get emailRequired => 'Enter your email address.';
|
||||
|
||||
@override
|
||||
String get emailInvalid => 'Enter a valid email address.';
|
||||
|
||||
@override
|
||||
String get nameRequired => 'Enter your name.';
|
||||
|
||||
@override
|
||||
String get passwordRequired => 'Enter your password.';
|
||||
|
||||
@override
|
||||
String get confirmationRequired => 'Confirm your password.';
|
||||
|
||||
@override
|
||||
String get passwordMismatch => 'Passwords do not match.';
|
||||
|
||||
@override
|
||||
String get language => 'Language';
|
||||
|
||||
@override
|
||||
String get preferenceNotSaved =>
|
||||
'Language changed for this session. Your preference could not be saved.';
|
||||
|
||||
@override
|
||||
String get authWorking => 'Please wait…';
|
||||
|
||||
@override
|
||||
String get authCompleted => 'Request completed.';
|
||||
|
||||
@override
|
||||
String get authInvalidCredentials =>
|
||||
'Email or password is incorrect. Please try again.';
|
||||
|
||||
@override
|
||||
String get authEmailInUse =>
|
||||
'This email is already registered. Try logging in.';
|
||||
|
||||
@override
|
||||
String get authUnavailable => 'The service is unavailable. Please try again.';
|
||||
|
||||
@override
|
||||
String get authUnexpected => 'Something went wrong. Please try again.';
|
||||
|
||||
@override
|
||||
String get checkInbox => 'Check your inbox.';
|
||||
|
||||
@override
|
||||
String get verificationSent =>
|
||||
'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.';
|
||||
|
||||
@override
|
||||
String get verificationSending => 'Preparing your verification email…';
|
||||
|
||||
@override
|
||||
String get verificationChecking => 'Verifying your email…';
|
||||
|
||||
@override
|
||||
String get verificationVerified => 'Your email\nis verified.';
|
||||
|
||||
@override
|
||||
String get verificationVerifiedBody =>
|
||||
'One step done. You can now return to login. Identity verification is a separate step.';
|
||||
|
||||
@override
|
||||
String get verificationExpired => 'This link has expired.';
|
||||
|
||||
@override
|
||||
String get verificationExpiredBody =>
|
||||
'Request a new verification email and use the latest link.';
|
||||
|
||||
@override
|
||||
String get verificationInvalid => 'This link isn’t valid.';
|
||||
|
||||
@override
|
||||
String get verificationInvalidBody =>
|
||||
'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.';
|
||||
|
||||
@override
|
||||
String get verificationInternal => 'Something went wrong.';
|
||||
|
||||
@override
|
||||
String get verificationInternalBody =>
|
||||
'We couldn’t complete your request because of an internal error. Please try again.';
|
||||
|
||||
@override
|
||||
String get verificationResend => 'Resend verification email';
|
||||
|
||||
@override
|
||||
String get verificationRequest => 'Send a new verification email';
|
||||
|
||||
@override
|
||||
String get verificationRetry => 'Retry verification';
|
||||
|
||||
@override
|
||||
String get verificationBack => 'Back to login';
|
||||
|
||||
@override
|
||||
String get verificationHint =>
|
||||
'No email? Check your spam folder or request another below.';
|
||||
|
||||
@override
|
||||
String get verificationDemoLink => 'Continue to confirmation';
|
||||
|
||||
@override
|
||||
String get verificationStep => 'CONFIRM YOUR EMAIL';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Recipient';
|
||||
|
||||
@override
|
||||
String get verificationChangeEmail => 'Change email';
|
||||
|
||||
@override
|
||||
String get kycTitle => 'Verification & limits';
|
||||
|
||||
@override
|
||||
String get kycIntro =>
|
||||
'Your account, at your pace. Verify your identity when you need higher limits.';
|
||||
|
||||
@override
|
||||
String get kycUnverified => 'Not verified';
|
||||
|
||||
@override
|
||||
String get kycPending => 'In review';
|
||||
|
||||
@override
|
||||
String get kycApproved => 'Identity verified';
|
||||
|
||||
@override
|
||||
String get kycChanges => 'New photos needed';
|
||||
|
||||
@override
|
||||
String get kycDenied => 'Verification declined';
|
||||
|
||||
@override
|
||||
String get kycUnverifiedBody =>
|
||||
'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.';
|
||||
|
||||
@override
|
||||
String get kycPendingBody =>
|
||||
'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.';
|
||||
|
||||
@override
|
||||
String get kycApprovedBody =>
|
||||
'Your review is complete. Check your current limits below.';
|
||||
|
||||
@override
|
||||
String get kycChangesBody =>
|
||||
'Your account remains available with basic limits. Replace the photos and submit a new review request.';
|
||||
|
||||
@override
|
||||
String get kycDeniedBody =>
|
||||
'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.';
|
||||
|
||||
@override
|
||||
String get kycPassportReason =>
|
||||
'The passport details could not be read. Retake the photo in good light, with every edge visible.';
|
||||
|
||||
@override
|
||||
String get kycSelfieReason =>
|
||||
'Your face or passport was not clear in the selfie. Keep both visible and in focus.';
|
||||
|
||||
@override
|
||||
String get kycExpiredReason =>
|
||||
'The passport has expired. Use a valid passport and take both photos again.';
|
||||
|
||||
@override
|
||||
String get kycDeniedReason =>
|
||||
'We could not verify your identity from this submission.';
|
||||
|
||||
@override
|
||||
String get kycStart => 'Verify identity';
|
||||
|
||||
@override
|
||||
String get kycResubmit => 'Replace photos';
|
||||
|
||||
@override
|
||||
String get kycRefresh => 'Refresh status & limits';
|
||||
|
||||
@override
|
||||
String get kycSupport => 'Contact support';
|
||||
|
||||
@override
|
||||
String get kycSupportBody =>
|
||||
'Support is currently unavailable. Please try again later.';
|
||||
|
||||
@override
|
||||
String get kycBasic => 'Basic';
|
||||
|
||||
@override
|
||||
String get kycVerifiedTier => 'Verified';
|
||||
|
||||
@override
|
||||
String get kycLimits => 'Your limits';
|
||||
|
||||
@override
|
||||
String get kycDay => 'Daily volume';
|
||||
|
||||
@override
|
||||
String get kycMonth => 'Monthly volume';
|
||||
|
||||
@override
|
||||
String get kycCurrent => 'Current cap';
|
||||
|
||||
@override
|
||||
String get kycRemaining => 'Available';
|
||||
|
||||
@override
|
||||
String get kycAfter => 'After approval';
|
||||
|
||||
@override
|
||||
String get kycLimitsError =>
|
||||
'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.';
|
||||
|
||||
@override
|
||||
String get kycLimitsEmpty =>
|
||||
'No limits were returned. Refresh or check with support before making a transaction.';
|
||||
|
||||
@override
|
||||
String get kycLoading => 'Loading your status…';
|
||||
|
||||
@override
|
||||
String get kycPassport => 'Passport photo';
|
||||
|
||||
@override
|
||||
String get kycSelfie => 'Selfie with passport';
|
||||
|
||||
@override
|
||||
String get kycReview => 'Review your photos';
|
||||
|
||||
@override
|
||||
String get kycPassportHelp =>
|
||||
'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.';
|
||||
|
||||
@override
|
||||
String get kycSelfieHelp =>
|
||||
'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.';
|
||||
|
||||
@override
|
||||
String get kycFileRules =>
|
||||
'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.';
|
||||
|
||||
@override
|
||||
String get kycChoose => 'Choose photo';
|
||||
|
||||
@override
|
||||
String get kycReplace => 'Choose a different photo';
|
||||
|
||||
@override
|
||||
String get kycRemove => 'Remove photo';
|
||||
|
||||
@override
|
||||
String get kycNext => 'Continue';
|
||||
|
||||
@override
|
||||
String get kycBack => 'Back';
|
||||
|
||||
@override
|
||||
String get kycSkip => 'Keep basic limits';
|
||||
|
||||
@override
|
||||
String get kycClose => 'Back to account';
|
||||
|
||||
@override
|
||||
String get kycReviewHelp =>
|
||||
'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.';
|
||||
|
||||
@override
|
||||
String get kycConfirm => 'These photos show me and my own valid passport.';
|
||||
|
||||
@override
|
||||
String get kycSubmit => 'Submit for review';
|
||||
|
||||
@override
|
||||
String get kycWorking => 'Processing… Please keep this page open.';
|
||||
|
||||
@override
|
||||
String get kycDiscardTitle => 'Discard these photos?';
|
||||
|
||||
@override
|
||||
String get kycDiscardBody =>
|
||||
'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.';
|
||||
|
||||
@override
|
||||
String get kycStay => 'Keep editing';
|
||||
|
||||
@override
|
||||
String get kycDiscard => 'Discard & leave';
|
||||
|
||||
@override
|
||||
String get kycErrorUnavailable =>
|
||||
'The service is unavailable. Your selected photos are still here. Try again.';
|
||||
|
||||
@override
|
||||
String get kycErrorInternal =>
|
||||
'Something went wrong. Try again; your selected photos have been kept.';
|
||||
|
||||
@override
|
||||
String get kycErrorPermission =>
|
||||
'We could not open that photo. Check file access or choose another image.';
|
||||
|
||||
@override
|
||||
String get kycErrorFormat =>
|
||||
'Choose a JPEG or PNG image. Other formats are not supported.';
|
||||
|
||||
@override
|
||||
String get kycErrorLarge =>
|
||||
'This image is too large. Choose one under 10 MB and 40 megapixels.';
|
||||
|
||||
@override
|
||||
String get kycErrorUnreadable =>
|
||||
'This image is damaged or cannot be read. Choose another photo.';
|
||||
|
||||
@override
|
||||
String get kycErrorSmall =>
|
||||
'The photo is too small. Use an image at least 600 pixels wide and high.';
|
||||
|
||||
@override
|
||||
String get kycErrorIncomplete =>
|
||||
'Choose both photos and confirm they belong to you before submitting.';
|
||||
|
||||
@override
|
||||
String get kycErrorSession =>
|
||||
'Your session has expired. Return to login, then check your verification status.';
|
||||
|
||||
@override
|
||||
String get kycErrorRate =>
|
||||
'Too many requests. Wait a moment before trying again.';
|
||||
|
||||
@override
|
||||
String get kycErrorConflict =>
|
||||
'Your review status has changed. Refresh it before continuing.';
|
||||
|
||||
@override
|
||||
String get kycDemoControls => 'Review scenarios';
|
||||
|
||||
@override
|
||||
String get kycDemoApprove => 'Approve';
|
||||
|
||||
@override
|
||||
String get kycDemoCorrect => 'Request clearer passport';
|
||||
|
||||
@override
|
||||
String get kycDemoSelfie => 'Request clearer selfie';
|
||||
|
||||
@override
|
||||
String get kycDemoExpired => 'Passport expired';
|
||||
|
||||
@override
|
||||
String get kycDemoDeny => 'Decline';
|
||||
|
||||
@override
|
||||
String get kycPreview => 'Explore verification & limits';
|
||||
|
||||
@override
|
||||
String get kycChooseFirst => 'Choose a photo to continue.';
|
||||
|
||||
@override
|
||||
String get kycStatusStale =>
|
||||
'Status could not be refreshed. The status shown is the last known result.';
|
||||
|
||||
@override
|
||||
String get kycUsed => 'Used';
|
||||
|
||||
@override
|
||||
String get kycReserved => 'Reserved';
|
||||
|
||||
@override
|
||||
String get kycUsageHelp =>
|
||||
'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.';
|
||||
|
||||
@override
|
||||
String get kycOccupied => 'Used + reserved';
|
||||
|
||||
@override
|
||||
String get kycNoCapacity => 'No capacity available';
|
||||
|
||||
@override
|
||||
String get wAccounts => 'Accounts';
|
||||
|
||||
@override
|
||||
String get wActivity => 'Activity';
|
||||
|
||||
@override
|
||||
String get wLimits => 'Limits';
|
||||
|
||||
@override
|
||||
String get wSettings => 'Settings';
|
||||
|
||||
@override
|
||||
String get wHeadline => 'Your money.\nYour next move.';
|
||||
|
||||
@override
|
||||
String get wSubheading => 'Top up. Exchange. Go further.';
|
||||
|
||||
@override
|
||||
String get wAvailable => 'Available to you';
|
||||
|
||||
@override
|
||||
String get wTopUp => 'Top up';
|
||||
|
||||
@override
|
||||
String get wExchange => 'Exchange';
|
||||
|
||||
@override
|
||||
String get wPayout => 'Pay out';
|
||||
|
||||
@override
|
||||
String get wRecent => 'Recent activity';
|
||||
|
||||
@override
|
||||
String get wSeeAll => 'See all';
|
||||
|
||||
@override
|
||||
String get wEmptyActivity =>
|
||||
'Your next move starts here. Your transactions will appear below.';
|
||||
|
||||
@override
|
||||
String get wEmptyAccounts =>
|
||||
'Your accounts are not ready yet. Try refreshing.';
|
||||
|
||||
@override
|
||||
String get wRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String get wSource => 'From account';
|
||||
|
||||
@override
|
||||
String get wDestination => 'To account';
|
||||
|
||||
@override
|
||||
String get wReceiveAccount => 'Receiving account';
|
||||
|
||||
@override
|
||||
String get wAmount => 'Amount';
|
||||
|
||||
@override
|
||||
String get wRecipient => 'Recipient';
|
||||
|
||||
@override
|
||||
String get wRecipientHint => 'Recipient name or reference';
|
||||
|
||||
@override
|
||||
String get wReview => 'Review';
|
||||
|
||||
@override
|
||||
String get wReviewTitle => 'One last look.';
|
||||
|
||||
@override
|
||||
String get wFee => 'Fee';
|
||||
|
||||
@override
|
||||
String get wTotalDebit => 'Leaves your account';
|
||||
|
||||
@override
|
||||
String get wCredit => 'Arrives in your account';
|
||||
|
||||
@override
|
||||
String get wRecipientGets => 'Recipient receives';
|
||||
|
||||
@override
|
||||
String get wYouPay => 'You pay';
|
||||
|
||||
@override
|
||||
String get wRate => 'Exchange rate';
|
||||
|
||||
@override
|
||||
String get wQuoteNotice =>
|
||||
'These terms are valid for 60 seconds. Confirm them before continuing.';
|
||||
|
||||
@override
|
||||
String get wExpired =>
|
||||
'This quote expired. Refresh it and review the new terms.';
|
||||
|
||||
@override
|
||||
String get wNewQuote => 'Refresh quote';
|
||||
|
||||
@override
|
||||
String get wConfirm => 'Confirm transaction';
|
||||
|
||||
@override
|
||||
String get wEdit => 'Edit details';
|
||||
|
||||
@override
|
||||
String get wClose => 'Close';
|
||||
|
||||
@override
|
||||
String get wWorking => 'Working on it…';
|
||||
|
||||
@override
|
||||
String get wPending => 'Processing';
|
||||
|
||||
@override
|
||||
String get wCompleted => 'Completed';
|
||||
|
||||
@override
|
||||
String get wFailed => 'Not completed';
|
||||
|
||||
@override
|
||||
String get wPendingTopUp =>
|
||||
'Your top-up is processing. Your available balance has not changed yet.';
|
||||
|
||||
@override
|
||||
String get wPendingExchange =>
|
||||
'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.';
|
||||
|
||||
@override
|
||||
String get wPendingPayout =>
|
||||
'The amount and fee have left your available balance. The payout is still processing.';
|
||||
|
||||
@override
|
||||
String get wCompleteTopUp =>
|
||||
'Your account has been credited. The money is available to use.';
|
||||
|
||||
@override
|
||||
String get wCompleteExchange =>
|
||||
'Your exchange is complete. The money is available in the receiving account.';
|
||||
|
||||
@override
|
||||
String get wCompletePayout =>
|
||||
'Your payout is complete. The amount and fee have been deducted from your account.';
|
||||
|
||||
@override
|
||||
String get wFailedTopUp =>
|
||||
'The top-up did not complete. No money was added to your account.';
|
||||
|
||||
@override
|
||||
String get wFailedOutgoing =>
|
||||
'The transaction did not complete. The amount and fee are available in your account again.';
|
||||
|
||||
@override
|
||||
String get wUnavailable =>
|
||||
'Could not refresh. The displayed information may be out of date. Try again.';
|
||||
|
||||
@override
|
||||
String get wInvalidAmount =>
|
||||
'Enter an amount greater than zero, using the currency’s decimal precision.';
|
||||
|
||||
@override
|
||||
String get wInsufficient =>
|
||||
'Not enough available money for this amount and fee. Top up or enter a smaller amount.';
|
||||
|
||||
@override
|
||||
String get wLimitError =>
|
||||
'This amount exceeds your available limit. Check your limits before continuing.';
|
||||
|
||||
@override
|
||||
String get wRecipientError =>
|
||||
'Enter a sample recipient between 6 and 140 characters.';
|
||||
|
||||
@override
|
||||
String get wUnsupported =>
|
||||
'This combination is unavailable. Choose another account.';
|
||||
|
||||
@override
|
||||
String get wNotFound =>
|
||||
'This item could not be found. Return to your accounts.';
|
||||
|
||||
@override
|
||||
String get wUncertain =>
|
||||
'We could not confirm the outcome. Check it before trying another transaction.';
|
||||
|
||||
@override
|
||||
String get wCheckOutcome => 'Check outcome';
|
||||
|
||||
@override
|
||||
String get wNoSubmission =>
|
||||
'No transaction was found. You can review and confirm again.';
|
||||
|
||||
@override
|
||||
String get wDiscardTitle => 'Leave this transaction?';
|
||||
|
||||
@override
|
||||
String get wDiscardBody =>
|
||||
'Your entered details will be cleared. No new transaction will be submitted.';
|
||||
|
||||
@override
|
||||
String get wStay => 'Keep editing';
|
||||
|
||||
@override
|
||||
String get wAll => 'All';
|
||||
|
||||
@override
|
||||
String get wSearch => 'Search by reference';
|
||||
|
||||
@override
|
||||
String get wNoMatches => 'No matching transactions. Try another filter.';
|
||||
|
||||
@override
|
||||
String get wClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get wReference => 'Reference';
|
||||
|
||||
@override
|
||||
String get wWhen => 'Created';
|
||||
|
||||
@override
|
||||
String get wViewAccount => 'View account';
|
||||
|
||||
@override
|
||||
String get wPreferences => 'Make it yours.';
|
||||
|
||||
@override
|
||||
String get wLanguage => 'Account language';
|
||||
|
||||
@override
|
||||
String get wLanguageHelp => 'Used throughout your account.';
|
||||
|
||||
@override
|
||||
String get wLanguageSaved => 'Saved to your account.';
|
||||
|
||||
@override
|
||||
String get wLanguageFailed =>
|
||||
'Changed for this session, but not saved to your account. Retry below.';
|
||||
|
||||
@override
|
||||
String get wProfileError =>
|
||||
'Account preferences could not be loaded. Try again.';
|
||||
|
||||
@override
|
||||
String get wSecurity => 'Security';
|
||||
|
||||
@override
|
||||
String get wSecurityBody =>
|
||||
'Password and security settings are not available yet.';
|
||||
|
||||
@override
|
||||
String get wIdentity => 'Identity verification';
|
||||
|
||||
@override
|
||||
String get wIdentityHelp => 'Optional verification for higher limits.';
|
||||
|
||||
@override
|
||||
String get wBackLogin => 'Back to login';
|
||||
|
||||
@override
|
||||
String get wEnter => 'Explore your accounts';
|
||||
|
||||
@override
|
||||
String get wUsage => 'Used this period';
|
||||
|
||||
@override
|
||||
String get wLimitsScope => 'Outgoing activity · EUR equivalent';
|
||||
|
||||
@override
|
||||
String get wPendingHint =>
|
||||
'Refresh to check the latest status. You can keep using your other accounts.';
|
||||
|
||||
@override
|
||||
String get wFundsHeading => 'Where is my money?';
|
||||
|
||||
@override
|
||||
String get wAnother => 'Make another transaction';
|
||||
|
||||
@override
|
||||
String get factorTitle => 'One more check.';
|
||||
|
||||
@override
|
||||
String get factorLogin => 'Confirm it’s you to finish signing in.';
|
||||
|
||||
@override
|
||||
String get factorMoney =>
|
||||
'Confirm this transaction. No money moves before confirmation.';
|
||||
|
||||
@override
|
||||
String get factorEnableHelp =>
|
||||
'Confirm to turn on two-factor verification for sign-in and money operations.';
|
||||
|
||||
@override
|
||||
String get factorDisableHelp =>
|
||||
'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.';
|
||||
|
||||
@override
|
||||
String get factorEmail => 'Enter the six-digit code sent to your email.';
|
||||
|
||||
@override
|
||||
String get factorAuthenticator =>
|
||||
'Enter the current six-digit code from your authenticator app.';
|
||||
|
||||
@override
|
||||
String get factorCode => 'Verification code';
|
||||
|
||||
@override
|
||||
String get factorConfirm => 'Verify and continue';
|
||||
|
||||
@override
|
||||
String get factorResend => 'Send a new code';
|
||||
|
||||
@override
|
||||
String get factorHelp =>
|
||||
'Check your inbox and spam folder. A new code replaces the previous one.';
|
||||
|
||||
@override
|
||||
String get factorAppHelp =>
|
||||
'Use the current code. If you cannot access your authenticator, cancel this action.';
|
||||
|
||||
@override
|
||||
String get factorInvalid =>
|
||||
'That code is not valid. Check all six digits and try again.';
|
||||
|
||||
@override
|
||||
String get factorExpired =>
|
||||
'This verification has expired. Request a new code or return to start again.';
|
||||
|
||||
@override
|
||||
String get factorLocked =>
|
||||
'Too many incorrect attempts. Wait five minutes, then start verification again.';
|
||||
|
||||
@override
|
||||
String get factorWait => 'Please wait before requesting another code.';
|
||||
|
||||
@override
|
||||
String get factorRequired =>
|
||||
'Verification is needed again. Please confirm this action once more.';
|
||||
|
||||
@override
|
||||
String get factorUnavailable =>
|
||||
'Could not complete verification. Try again or cancel this action.';
|
||||
|
||||
@override
|
||||
String factorCooldown(int seconds) {
|
||||
return 'Send a new code in ${seconds}s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get factorSettings => 'Two-factor verification';
|
||||
|
||||
@override
|
||||
String get factorSettingsHelp =>
|
||||
'Add a verification code when you sign in, top up, exchange or pay out.';
|
||||
|
||||
@override
|
||||
String get factorOn => 'On';
|
||||
|
||||
@override
|
||||
String get factorOff => 'Off';
|
||||
|
||||
@override
|
||||
String get factorEnable => 'Turn on';
|
||||
|
||||
@override
|
||||
String get factorDisable => 'Turn off';
|
||||
|
||||
@override
|
||||
String get factorEmailMethod => 'Email code';
|
||||
|
||||
@override
|
||||
String get factorAppMethod => 'Authenticator app';
|
||||
|
||||
@override
|
||||
String get factorAppExpired =>
|
||||
'This verification has expired. Return and start again.';
|
||||
}
|
||||
@@ -1,857 +0,0 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Russian (`ru`).
|
||||
class AppLocalizationsRu extends AppLocalizations {
|
||||
AppLocalizationsRu([String locale = 'ru']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'SXCHNGE · Ваш следующий шаг';
|
||||
|
||||
@override
|
||||
String get nextChapter => 'НОВАЯ ГЛАВА';
|
||||
|
||||
@override
|
||||
String get loginHeading => 'Рады видеть\nвас снова.';
|
||||
|
||||
@override
|
||||
String get signupHeading => 'Сделайте\nпервый шаг.';
|
||||
|
||||
@override
|
||||
String get loginIntro => 'Войдите, чтобы сделать следующий шаг.';
|
||||
|
||||
@override
|
||||
String get signupIntro => 'Новый аккаунт. Новые возможности.';
|
||||
|
||||
@override
|
||||
String get login => 'Войти';
|
||||
|
||||
@override
|
||||
String get signup => 'Регистрация';
|
||||
|
||||
@override
|
||||
String get name => 'Ваше имя';
|
||||
|
||||
@override
|
||||
String get email => 'Электронная почта';
|
||||
|
||||
@override
|
||||
String get emailHint => 'you@example.com';
|
||||
|
||||
@override
|
||||
String get password => 'Пароль';
|
||||
|
||||
@override
|
||||
String get confirmPassword => 'Повторите пароль';
|
||||
|
||||
@override
|
||||
String get passwordHint => 'Используйте не менее 12 символов.';
|
||||
|
||||
@override
|
||||
String get showPassword => 'Показать пароль';
|
||||
|
||||
@override
|
||||
String get hidePassword => 'Скрыть пароль';
|
||||
|
||||
@override
|
||||
String get showConfirmation => 'Показать повтор пароля';
|
||||
|
||||
@override
|
||||
String get hideConfirmation => 'Скрыть повтор пароля';
|
||||
|
||||
@override
|
||||
String get createAccount => 'Создать аккаунт';
|
||||
|
||||
@override
|
||||
String get letsGo => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get loginPreview => 'Вы вошли в аккаунт.';
|
||||
|
||||
@override
|
||||
String get signupPreview => 'Ваш аккаунт готов.';
|
||||
|
||||
@override
|
||||
String get alreadyMember => 'Уже есть аккаунт sxchnge?';
|
||||
|
||||
@override
|
||||
String get newHere => 'Впервые здесь?';
|
||||
|
||||
@override
|
||||
String get loginInstead => 'Войти в аккаунт';
|
||||
|
||||
@override
|
||||
String get createAnAccount => 'Зарегистрироваться';
|
||||
|
||||
@override
|
||||
String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.';
|
||||
|
||||
@override
|
||||
String get brandHeading => 'Всё начинается\nс перемен.';
|
||||
|
||||
@override
|
||||
String get brandIntro =>
|
||||
'Новый путь вперёд.\nОткройте его вместе со sxchnge.';
|
||||
|
||||
@override
|
||||
String get brandCard => 'Меньше преград.\nБольше возможностей.';
|
||||
|
||||
@override
|
||||
String get brandFooter => 'СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.';
|
||||
|
||||
@override
|
||||
String get emailRequired => 'Введите электронную почту.';
|
||||
|
||||
@override
|
||||
String get emailInvalid => 'Введите корректный адрес почты.';
|
||||
|
||||
@override
|
||||
String get nameRequired => 'Введите ваше имя.';
|
||||
|
||||
@override
|
||||
String get passwordRequired => 'Введите пароль.';
|
||||
|
||||
@override
|
||||
String get confirmationRequired => 'Повторите пароль.';
|
||||
|
||||
@override
|
||||
String get passwordMismatch => 'Пароли не совпадают.';
|
||||
|
||||
@override
|
||||
String get language => 'Язык';
|
||||
|
||||
@override
|
||||
String get preferenceNotSaved =>
|
||||
'Язык изменён для этого сеанса. Сохранить выбор не удалось.';
|
||||
|
||||
@override
|
||||
String get authWorking => 'Подождите…';
|
||||
|
||||
@override
|
||||
String get authCompleted => 'Запрос выполнен.';
|
||||
|
||||
@override
|
||||
String get authInvalidCredentials =>
|
||||
'Неверная почта или пароль. Попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get authEmailInUse =>
|
||||
'Эта почта уже зарегистрирована. Попробуйте войти.';
|
||||
|
||||
@override
|
||||
String get authUnavailable => 'Сервис недоступен. Попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get authUnexpected => 'Что-то пошло не так. Попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get checkInbox => 'Проверьте почту.';
|
||||
|
||||
@override
|
||||
String get verificationSent =>
|
||||
'Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.';
|
||||
|
||||
@override
|
||||
String get verificationSending => 'Готовим письмо для подтверждения…';
|
||||
|
||||
@override
|
||||
String get verificationChecking => 'Подтверждаем вашу почту…';
|
||||
|
||||
@override
|
||||
String get verificationVerified => 'Ваша почта\nподтверждена.';
|
||||
|
||||
@override
|
||||
String get verificationVerifiedBody =>
|
||||
'Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.';
|
||||
|
||||
@override
|
||||
String get verificationExpired => 'Срок действия ссылки истёк.';
|
||||
|
||||
@override
|
||||
String get verificationExpiredBody =>
|
||||
'Запросите новое письмо и перейдите по ссылке из него.';
|
||||
|
||||
@override
|
||||
String get verificationInvalid => 'Ссылка недействительна.';
|
||||
|
||||
@override
|
||||
String get verificationInvalidBody =>
|
||||
'Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.';
|
||||
|
||||
@override
|
||||
String get verificationInternal => 'Что-то пошло не так.';
|
||||
|
||||
@override
|
||||
String get verificationInternalBody =>
|
||||
'Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get verificationResend => 'Отправить письмо ещё раз';
|
||||
|
||||
@override
|
||||
String get verificationRequest => 'Отправить новое письмо';
|
||||
|
||||
@override
|
||||
String get verificationRetry => 'Повторить подтверждение';
|
||||
|
||||
@override
|
||||
String get verificationBack => 'Вернуться ко входу';
|
||||
|
||||
@override
|
||||
String get verificationHint =>
|
||||
'Нет письма? Проверьте папку «Спам» или запросите новое ниже.';
|
||||
|
||||
@override
|
||||
String get verificationDemoLink => 'Перейти к подтверждению';
|
||||
|
||||
@override
|
||||
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Получатель';
|
||||
|
||||
@override
|
||||
String get verificationChangeEmail => 'Изменить почту';
|
||||
|
||||
@override
|
||||
String get kycTitle => 'Верификация и лимиты';
|
||||
|
||||
@override
|
||||
String get kycIntro =>
|
||||
'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.';
|
||||
|
||||
@override
|
||||
String get kycUnverified => 'Личность не подтверждена';
|
||||
|
||||
@override
|
||||
String get kycPending => 'На проверке';
|
||||
|
||||
@override
|
||||
String get kycApproved => 'Личность подтверждена';
|
||||
|
||||
@override
|
||||
String get kycChanges => 'Нужны новые фото';
|
||||
|
||||
@override
|
||||
String get kycDenied => 'Верификация отклонена';
|
||||
|
||||
@override
|
||||
String get kycUnverifiedBody =>
|
||||
'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.';
|
||||
|
||||
@override
|
||||
String get kycPendingBody =>
|
||||
'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.';
|
||||
|
||||
@override
|
||||
String get kycApprovedBody =>
|
||||
'Проверка завершена. Актуальные лимиты указаны ниже.';
|
||||
|
||||
@override
|
||||
String get kycChangesBody =>
|
||||
'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.';
|
||||
|
||||
@override
|
||||
String get kycDeniedBody =>
|
||||
'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.';
|
||||
|
||||
@override
|
||||
String get kycPassportReason =>
|
||||
'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.';
|
||||
|
||||
@override
|
||||
String get kycSelfieReason =>
|
||||
'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.';
|
||||
|
||||
@override
|
||||
String get kycExpiredReason =>
|
||||
'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.';
|
||||
|
||||
@override
|
||||
String get kycDeniedReason =>
|
||||
'По этой заявке не удалось подтвердить вашу личность.';
|
||||
|
||||
@override
|
||||
String get kycStart => 'Подтвердить личность';
|
||||
|
||||
@override
|
||||
String get kycResubmit => 'Заменить фото';
|
||||
|
||||
@override
|
||||
String get kycRefresh => 'Обновить статус и лимиты';
|
||||
|
||||
@override
|
||||
String get kycSupport => 'Обратиться в поддержку';
|
||||
|
||||
@override
|
||||
String get kycSupportBody => 'Поддержка сейчас недоступна. Попробуйте позже.';
|
||||
|
||||
@override
|
||||
String get kycBasic => 'Базовый';
|
||||
|
||||
@override
|
||||
String get kycVerifiedTier => 'Подтверждённый';
|
||||
|
||||
@override
|
||||
String get kycLimits => 'Ваши лимиты';
|
||||
|
||||
@override
|
||||
String get kycDay => 'Объём за день';
|
||||
|
||||
@override
|
||||
String get kycMonth => 'Объём за месяц';
|
||||
|
||||
@override
|
||||
String get kycCurrent => 'Текущий лимит';
|
||||
|
||||
@override
|
||||
String get kycRemaining => 'Доступно';
|
||||
|
||||
@override
|
||||
String get kycAfter => 'После одобрения';
|
||||
|
||||
@override
|
||||
String get kycLimitsError =>
|
||||
'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.';
|
||||
|
||||
@override
|
||||
String get kycLimitsEmpty =>
|
||||
'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.';
|
||||
|
||||
@override
|
||||
String get kycLoading => 'Загружаем ваш статус…';
|
||||
|
||||
@override
|
||||
String get kycPassport => 'Фото паспорта';
|
||||
|
||||
@override
|
||||
String get kycSelfie => 'Селфи с паспортом';
|
||||
|
||||
@override
|
||||
String get kycReview => 'Проверьте фото';
|
||||
|
||||
@override
|
||||
String get kycPassportHelp =>
|
||||
'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.';
|
||||
|
||||
@override
|
||||
String get kycSelfieHelp =>
|
||||
'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.';
|
||||
|
||||
@override
|
||||
String get kycFileRules =>
|
||||
'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.';
|
||||
|
||||
@override
|
||||
String get kycChoose => 'Выбрать фото';
|
||||
|
||||
@override
|
||||
String get kycReplace => 'Выбрать другое фото';
|
||||
|
||||
@override
|
||||
String get kycRemove => 'Удалить фото';
|
||||
|
||||
@override
|
||||
String get kycNext => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get kycBack => 'Назад';
|
||||
|
||||
@override
|
||||
String get kycSkip => 'Оставить базовые лимиты';
|
||||
|
||||
@override
|
||||
String get kycClose => 'К аккаунту';
|
||||
|
||||
@override
|
||||
String get kycReviewHelp =>
|
||||
'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.';
|
||||
|
||||
@override
|
||||
String get kycConfirm => 'На фото я и мой действующий паспорт.';
|
||||
|
||||
@override
|
||||
String get kycSubmit => 'Отправить на проверку';
|
||||
|
||||
@override
|
||||
String get kycWorking => 'Обработка… Не закрывайте страницу.';
|
||||
|
||||
@override
|
||||
String get kycDiscardTitle => 'Удалить выбранные фото?';
|
||||
|
||||
@override
|
||||
String get kycDiscardBody =>
|
||||
'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.';
|
||||
|
||||
@override
|
||||
String get kycStay => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get kycDiscard => 'Удалить и выйти';
|
||||
|
||||
@override
|
||||
String get kycErrorUnavailable =>
|
||||
'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.';
|
||||
|
||||
@override
|
||||
String get kycErrorInternal =>
|
||||
'Произошла ошибка. Повторите попытку; выбранные фото сохранены.';
|
||||
|
||||
@override
|
||||
String get kycErrorPermission =>
|
||||
'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.';
|
||||
|
||||
@override
|
||||
String get kycErrorFormat =>
|
||||
'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.';
|
||||
|
||||
@override
|
||||
String get kycErrorLarge =>
|
||||
'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.';
|
||||
|
||||
@override
|
||||
String get kycErrorUnreadable =>
|
||||
'Изображение повреждено или не читается. Выберите другое фото.';
|
||||
|
||||
@override
|
||||
String get kycErrorSmall =>
|
||||
'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.';
|
||||
|
||||
@override
|
||||
String get kycErrorIncomplete =>
|
||||
'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.';
|
||||
|
||||
@override
|
||||
String get kycErrorSession =>
|
||||
'Сессия истекла. Войдите снова и проверьте статус верификации.';
|
||||
|
||||
@override
|
||||
String get kycErrorRate =>
|
||||
'Слишком много запросов. Немного подождите и повторите попытку.';
|
||||
|
||||
@override
|
||||
String get kycErrorConflict =>
|
||||
'Статус проверки изменился. Обновите его, прежде чем продолжить.';
|
||||
|
||||
@override
|
||||
String get kycDemoControls => 'Сценарии проверки';
|
||||
|
||||
@override
|
||||
String get kycDemoApprove => 'Одобрить';
|
||||
|
||||
@override
|
||||
String get kycDemoCorrect => 'Запросить чёткое фото паспорта';
|
||||
|
||||
@override
|
||||
String get kycDemoSelfie => 'Запросить чёткое селфи';
|
||||
|
||||
@override
|
||||
String get kycDemoExpired => 'Паспорт просрочен';
|
||||
|
||||
@override
|
||||
String get kycDemoDeny => 'Отклонить';
|
||||
|
||||
@override
|
||||
String get kycPreview => 'Посмотреть верификацию и лимиты';
|
||||
|
||||
@override
|
||||
String get kycChooseFirst => 'Выберите фото для продолжения.';
|
||||
|
||||
@override
|
||||
String get kycStatusStale =>
|
||||
'Не удалось обновить статус. Показан последний известный результат.';
|
||||
|
||||
@override
|
||||
String get kycUsed => 'Использовано';
|
||||
|
||||
@override
|
||||
String get kycReserved => 'Зарезервировано';
|
||||
|
||||
@override
|
||||
String get kycUsageHelp =>
|
||||
'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.';
|
||||
|
||||
@override
|
||||
String get kycOccupied => 'Использовано + зарезервировано';
|
||||
|
||||
@override
|
||||
String get kycNoCapacity => 'Доступный объём исчерпан';
|
||||
|
||||
@override
|
||||
String get wAccounts => 'Счета';
|
||||
|
||||
@override
|
||||
String get wActivity => 'История';
|
||||
|
||||
@override
|
||||
String get wLimits => 'Лимиты';
|
||||
|
||||
@override
|
||||
String get wSettings => 'Настройки';
|
||||
|
||||
@override
|
||||
String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.';
|
||||
|
||||
@override
|
||||
String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.';
|
||||
|
||||
@override
|
||||
String get wAvailable => 'Вам доступно';
|
||||
|
||||
@override
|
||||
String get wTopUp => 'Пополнить';
|
||||
|
||||
@override
|
||||
String get wExchange => 'Обменять';
|
||||
|
||||
@override
|
||||
String get wPayout => 'Вывести';
|
||||
|
||||
@override
|
||||
String get wRecent => 'Последние операции';
|
||||
|
||||
@override
|
||||
String get wSeeAll => 'Смотреть все';
|
||||
|
||||
@override
|
||||
String get wEmptyActivity =>
|
||||
'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.';
|
||||
|
||||
@override
|
||||
String get wEmptyAccounts =>
|
||||
'Ваши счета ещё не готовы. Попробуйте обновить страницу.';
|
||||
|
||||
@override
|
||||
String get wRefresh => 'Обновить';
|
||||
|
||||
@override
|
||||
String get wSource => 'Со счёта';
|
||||
|
||||
@override
|
||||
String get wDestination => 'На счёт';
|
||||
|
||||
@override
|
||||
String get wReceiveAccount => 'Счёт для зачисления';
|
||||
|
||||
@override
|
||||
String get wAmount => 'Сумма';
|
||||
|
||||
@override
|
||||
String get wRecipient => 'Получатель';
|
||||
|
||||
@override
|
||||
String get wRecipientHint => 'Имя или реквизиты получателя';
|
||||
|
||||
@override
|
||||
String get wReview => 'Проверить';
|
||||
|
||||
@override
|
||||
String get wReviewTitle => 'Проверьте детали.';
|
||||
|
||||
@override
|
||||
String get wFee => 'Комиссия';
|
||||
|
||||
@override
|
||||
String get wTotalDebit => 'Спишется со счёта';
|
||||
|
||||
@override
|
||||
String get wCredit => 'Поступит на счёт';
|
||||
|
||||
@override
|
||||
String get wRecipientGets => 'Получатель получит';
|
||||
|
||||
@override
|
||||
String get wYouPay => 'Вы платите';
|
||||
|
||||
@override
|
||||
String get wRate => 'Курс обмена';
|
||||
|
||||
@override
|
||||
String get wQuoteNotice =>
|
||||
'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.';
|
||||
|
||||
@override
|
||||
String get wExpired =>
|
||||
'Срок действия расчёта истёк. Обновите его и проверьте новые условия.';
|
||||
|
||||
@override
|
||||
String get wNewQuote => 'Обновить расчёт';
|
||||
|
||||
@override
|
||||
String get wConfirm => 'Подтвердить операцию';
|
||||
|
||||
@override
|
||||
String get wEdit => 'Изменить данные';
|
||||
|
||||
@override
|
||||
String get wClose => 'Закрыть';
|
||||
|
||||
@override
|
||||
String get wWorking => 'Обрабатываем…';
|
||||
|
||||
@override
|
||||
String get wPending => 'В обработке';
|
||||
|
||||
@override
|
||||
String get wCompleted => 'Завершено';
|
||||
|
||||
@override
|
||||
String get wFailed => 'Не выполнено';
|
||||
|
||||
@override
|
||||
String get wPendingTopUp =>
|
||||
'Пополнение обрабатывается. Доступный баланс пока не изменился.';
|
||||
|
||||
@override
|
||||
String get wPendingExchange =>
|
||||
'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.';
|
||||
|
||||
@override
|
||||
String get wPendingPayout =>
|
||||
'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.';
|
||||
|
||||
@override
|
||||
String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.';
|
||||
|
||||
@override
|
||||
String get wCompleteExchange =>
|
||||
'Обмен завершён. Деньги доступны на счёте зачисления.';
|
||||
|
||||
@override
|
||||
String get wCompletePayout =>
|
||||
'Вывод завершён. Сумма и комиссия списаны с вашего счёта.';
|
||||
|
||||
@override
|
||||
String get wFailedTopUp =>
|
||||
'Пополнение не выполнено. На счёт ничего не зачислено.';
|
||||
|
||||
@override
|
||||
String get wFailedOutgoing =>
|
||||
'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.';
|
||||
|
||||
@override
|
||||
String get wUnavailable =>
|
||||
'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.';
|
||||
|
||||
@override
|
||||
String get wInvalidAmount =>
|
||||
'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.';
|
||||
|
||||
@override
|
||||
String get wInsufficient =>
|
||||
'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.';
|
||||
|
||||
@override
|
||||
String get wLimitError =>
|
||||
'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.';
|
||||
|
||||
@override
|
||||
String get wRecipientError =>
|
||||
'Введите тестового получателя длиной от 6 до 140 символов.';
|
||||
|
||||
@override
|
||||
String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.';
|
||||
|
||||
@override
|
||||
String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.';
|
||||
|
||||
@override
|
||||
String get wUncertain =>
|
||||
'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.';
|
||||
|
||||
@override
|
||||
String get wCheckOutcome => 'Проверить результат';
|
||||
|
||||
@override
|
||||
String get wNoSubmission =>
|
||||
'Операция не найдена. Вы можете снова проверить и подтвердить её.';
|
||||
|
||||
@override
|
||||
String get wDiscardTitle => 'Выйти из операции?';
|
||||
|
||||
@override
|
||||
String get wDiscardBody =>
|
||||
'Введённые данные будут удалены. Новая операция не будет отправлена.';
|
||||
|
||||
@override
|
||||
String get wStay => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get wAll => 'Все';
|
||||
|
||||
@override
|
||||
String get wSearch => 'Поиск по номеру';
|
||||
|
||||
@override
|
||||
String get wNoMatches => 'Операции не найдены. Измените фильтр.';
|
||||
|
||||
@override
|
||||
String get wClearFilters => 'Сбросить фильтры';
|
||||
|
||||
@override
|
||||
String get wReference => 'Номер операции';
|
||||
|
||||
@override
|
||||
String get wWhen => 'Создано';
|
||||
|
||||
@override
|
||||
String get wViewAccount => 'Открыть счёт';
|
||||
|
||||
@override
|
||||
String get wPreferences => 'Настройте под себя.';
|
||||
|
||||
@override
|
||||
String get wLanguage => 'Язык аккаунта';
|
||||
|
||||
@override
|
||||
String get wLanguageHelp => 'Используется во всём аккаунте.';
|
||||
|
||||
@override
|
||||
String get wLanguageSaved => 'Сохранено в аккаунте.';
|
||||
|
||||
@override
|
||||
String get wLanguageFailed =>
|
||||
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.';
|
||||
|
||||
@override
|
||||
String get wProfileError =>
|
||||
'Не удалось загрузить настройки аккаунта. Повторите попытку.';
|
||||
|
||||
@override
|
||||
String get wSecurity => 'Безопасность';
|
||||
|
||||
@override
|
||||
String get wSecurityBody =>
|
||||
'Настройки пароля и безопасности пока недоступны.';
|
||||
|
||||
@override
|
||||
String get wIdentity => 'Подтверждение личности';
|
||||
|
||||
@override
|
||||
String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.';
|
||||
|
||||
@override
|
||||
String get wBackLogin => 'Вернуться ко входу';
|
||||
|
||||
@override
|
||||
String get wEnter => 'Открыть свои счета';
|
||||
|
||||
@override
|
||||
String get wUsage => 'Использовано за период';
|
||||
|
||||
@override
|
||||
String get wLimitsScope => 'Исходящие операции · эквивалент в EUR';
|
||||
|
||||
@override
|
||||
String get wPendingHint =>
|
||||
'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.';
|
||||
|
||||
@override
|
||||
String get wFundsHeading => 'Где мои деньги?';
|
||||
|
||||
@override
|
||||
String get wAnother => 'Создать новую операцию';
|
||||
|
||||
@override
|
||||
String get factorTitle => 'Ещё одна проверка.';
|
||||
|
||||
@override
|
||||
String get factorLogin => 'Подтвердите, что это вы, чтобы завершить вход.';
|
||||
|
||||
@override
|
||||
String get factorMoney =>
|
||||
'Подтвердите эту операцию. До подтверждения деньги не переводятся.';
|
||||
|
||||
@override
|
||||
String get factorEnableHelp =>
|
||||
'Подтвердите включение двухфакторной проверки для входа и операций с деньгами.';
|
||||
|
||||
@override
|
||||
String get factorDisableHelp =>
|
||||
'Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.';
|
||||
|
||||
@override
|
||||
String get factorEmail => 'Введите шестизначный код из письма.';
|
||||
|
||||
@override
|
||||
String get factorAuthenticator =>
|
||||
'Введите текущий шестизначный код из приложения-аутентификатора.';
|
||||
|
||||
@override
|
||||
String get factorCode => 'Код подтверждения';
|
||||
|
||||
@override
|
||||
String get factorConfirm => 'Подтвердить и продолжить';
|
||||
|
||||
@override
|
||||
String get factorResend => 'Отправить новый код';
|
||||
|
||||
@override
|
||||
String get factorHelp =>
|
||||
'Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.';
|
||||
|
||||
@override
|
||||
String get factorAppHelp =>
|
||||
'Используйте текущий код. Если приложение недоступно, отмените это действие.';
|
||||
|
||||
@override
|
||||
String get factorInvalid =>
|
||||
'Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get factorExpired =>
|
||||
'Срок проверки истёк. Запросите новый код или вернитесь и начните снова.';
|
||||
|
||||
@override
|
||||
String get factorLocked =>
|
||||
'Слишком много неверных попыток. Подождите пять минут и начните проверку снова.';
|
||||
|
||||
@override
|
||||
String get factorWait => 'Подождите перед повторным запросом кода.';
|
||||
|
||||
@override
|
||||
String get factorRequired =>
|
||||
'Нужна повторная проверка. Подтвердите это действие ещё раз.';
|
||||
|
||||
@override
|
||||
String get factorUnavailable =>
|
||||
'Не удалось завершить проверку. Повторите попытку или отмените действие.';
|
||||
|
||||
@override
|
||||
String factorCooldown(int seconds) {
|
||||
return 'Новый код через $seconds с';
|
||||
}
|
||||
|
||||
@override
|
||||
String get factorSettings => 'Двухфакторная проверка';
|
||||
|
||||
@override
|
||||
String get factorSettingsHelp =>
|
||||
'Подтверждайте кодом вход, пополнение, обмен и вывод средств.';
|
||||
|
||||
@override
|
||||
String get factorOn => 'Включена';
|
||||
|
||||
@override
|
||||
String get factorOff => 'Выключена';
|
||||
|
||||
@override
|
||||
String get factorEnable => 'Включить';
|
||||
|
||||
@override
|
||||
String get factorDisable => 'Выключить';
|
||||
|
||||
@override
|
||||
String get factorEmailMethod => 'Код из письма';
|
||||
|
||||
@override
|
||||
String get factorAppMethod => 'Приложение-аутентификатор';
|
||||
|
||||
@override
|
||||
String get factorAppExpired =>
|
||||
'Срок проверки истёк. Вернитесь и начните снова.';
|
||||
}
|
||||
@@ -43,10 +43,11 @@
|
||||
"authCompleted": "Request completed.",
|
||||
"authInvalidCredentials": "Email or password is incorrect. Please try again.",
|
||||
"authEmailInUse": "This email is already registered. Try logging in.",
|
||||
"authVerifyEmail": "Confirm your email before signing in.",
|
||||
"authUnavailable": "The service is unavailable. Please try again.",
|
||||
"authUnexpected": "Something went wrong. Please try again.",
|
||||
"checkInbox": "Check your inbox.",
|
||||
"verificationSent": "We’ve sent you a verification email. Open the link in your inbox to confirm your email address.",
|
||||
"verificationSent": "If this email belongs to an unverified account, a verification email is on its way. Open the link in your inbox to confirm your address.",
|
||||
"verificationSending": "Preparing your verification email…",
|
||||
"verificationChecking": "Verifying your email…",
|
||||
"verificationVerified": "Your email\nis verified.",
|
||||
|
||||
@@ -43,10 +43,11 @@
|
||||
"authCompleted": "Запрос выполнен.",
|
||||
"authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.",
|
||||
"authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.",
|
||||
"authVerifyEmail": "Подтвердите почту перед входом.",
|
||||
"authUnavailable": "Сервис недоступен. Попробуйте ещё раз.",
|
||||
"authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.",
|
||||
"checkInbox": "Проверьте почту.",
|
||||
"verificationSent": "Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.",
|
||||
"verificationSent": "Если эта почта принадлежит неподтверждённому аккаунту, письмо с подтверждением уже в пути. Откройте ссылку в письме, чтобы подтвердить адрес.",
|
||||
"verificationSending": "Готовим письмо для подтверждения…",
|
||||
"verificationChecking": "Подтверждаем вашу почту…",
|
||||
"verificationVerified": "Ваша почта\nподтверждена.",
|
||||
|
||||
@@ -20,13 +20,19 @@ import 'generated/i18n/app_localizations.dart';
|
||||
Future<void> main() async {
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||
runApp(SxchngeApp(localeProvider: locale));
|
||||
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
||||
const clientId = String.fromEnvironment('SXCHNGE_CLIENT_ID');
|
||||
final backend = apiUrl.isEmpty
|
||||
? null
|
||||
: BackendAuthServices(baseUri: Uri.parse(apiUrl), clientId: clientId);
|
||||
runApp(SxchngeApp(localeProvider: locale, backend: backend));
|
||||
}
|
||||
|
||||
class SxchngeApp extends StatefulWidget {
|
||||
const SxchngeApp({
|
||||
super.key,
|
||||
this.localeProvider,
|
||||
this.backend,
|
||||
this.analyticsService,
|
||||
this.identityService,
|
||||
this.limitsService,
|
||||
@@ -38,6 +44,7 @@ class SxchngeApp extends StatefulWidget {
|
||||
this.verificationService = const DummyVerificationService(),
|
||||
});
|
||||
final LocaleProvider? localeProvider;
|
||||
final BackendAuthServices? backend;
|
||||
final AnalyticsService? analyticsService;
|
||||
final IdentityService? identityService;
|
||||
final LimitsService? limitsService;
|
||||
@@ -56,7 +63,8 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||
|
||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||
late final _factors = widget.twoFactorService ?? DummyTwoFactorService();
|
||||
late final _factors =
|
||||
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
||||
late final _security = SecurityProvider(_factors, _analytics);
|
||||
late final _identity = widget.identityService ?? DummyIdentityService();
|
||||
late final _walletService =
|
||||
@@ -79,12 +87,18 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _journeys = JourneyRecorder(_analytics);
|
||||
late final _services = InstrumentedAuthServices(
|
||||
recorder: _journeys,
|
||||
loginService: GuardedLoginService(widget.loginService, _factors),
|
||||
signupService: widget.signupService,
|
||||
verificationService: widget.verificationService,
|
||||
loginService: GuardedLoginService(
|
||||
widget.backend ?? widget.loginService,
|
||||
_factors,
|
||||
),
|
||||
signupService: widget.backend ?? widget.signupService,
|
||||
verificationService: widget.backend ?? widget.verificationService,
|
||||
);
|
||||
late final _router = createAppRouter()
|
||||
..routeInformationProvider.addListener(_routeChanged);
|
||||
late final _router = createAppRouter(
|
||||
authenticated: widget.backend == null
|
||||
? null
|
||||
: () => widget.backend!.session != null,
|
||||
)..routeInformationProvider.addListener(_routeChanged);
|
||||
String _previousPath = '/';
|
||||
|
||||
@override
|
||||
@@ -109,6 +123,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
_wallet.dispose();
|
||||
_security.dispose();
|
||||
_locale.dispose();
|
||||
widget.backend?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,23 @@ import '../workspace/shell.dart';
|
||||
import '../workspace/transfer.dart';
|
||||
import 'routes.dart';
|
||||
|
||||
GoRouter createAppRouter() {
|
||||
GoRouter createAppRouter({bool Function()? authenticated}) {
|
||||
final topUpKey = GlobalKey<TransferPageState>();
|
||||
final exchangeKey = GlobalKey<TransferPageState>();
|
||||
final payoutKey = GlobalKey<TransferPageState>();
|
||||
final identityKey = GlobalKey<IdentityPageState>();
|
||||
return GoRouter(
|
||||
redirect: (context, state) {
|
||||
if (authenticated == null || authenticated()) return null;
|
||||
final path = state.uri.path;
|
||||
if (path == '/' ||
|
||||
path == '/verify-email' ||
|
||||
path == '/verification-sent' ||
|
||||
path == '/two-factor') {
|
||||
return null;
|
||||
}
|
||||
return '/';
|
||||
},
|
||||
// Signup email is an in-memory navigation extra, never browser-history data.
|
||||
extraCodec: const _TransientExtras(),
|
||||
routes: [
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
@@ -21,7 +23,63 @@ class ControlledLogin implements LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
class UnverifiedBackend extends BackendAuthServices {
|
||||
UnverifiedBackend()
|
||||
: super(baseUri: Uri.parse('https://api.example/api/v1'), clientId: 'web');
|
||||
int sends = 0;
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest request) async =>
|
||||
throw const AuthException(AuthError.accountNotVerified);
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
sends++;
|
||||
return const VerificationResult(isSimulated: false);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('backend mode gates workspace and recovers unverified login', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1440, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final backend = UnverifiedBackend();
|
||||
await tester.pumpWidget(SxchngeApp(backend: backend));
|
||||
await tester.pumpAndSettle();
|
||||
final router = GoRouter.of(
|
||||
tester.element(find.byKey(const ValueKey('email'))),
|
||||
);
|
||||
router.go('/accounts');
|
||||
await tester.pumpAndSettle();
|
||||
expect(router.routeInformationProvider.value.uri.path, '/');
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'user@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'password-value',
|
||||
);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
router.routeInformationProvider.value.uri.path,
|
||||
'/verification-sent',
|
||||
);
|
||||
expect(backend.sends, 1);
|
||||
expect(
|
||||
find.textContaining('a verification email is on its way'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'app shows loading, localized failure, and retries through injected service',
|
||||
(tester) async {
|
||||
|
||||
@@ -131,7 +131,10 @@ void main() {
|
||||
);
|
||||
await tap(tester, find.byKey(const ValueKey('submit')));
|
||||
expect(find.text('Check your inbox.'), findsOneWidget);
|
||||
expect(find.textContaining('We’ve sent you'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('a verification email is on its way'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
|
||||
expect(find.text('Your email\nis verified.'), findsOneWidget);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class BackendDTO {
|
||||
BackendDTO(this.data);
|
||||
factory BackendDTO.decode(String body) => BackendDTO(
|
||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||
);
|
||||
final Map<String, dynamic> data;
|
||||
String get error => data['error'] as String? ?? 'unavailable';
|
||||
String get userId =>
|
||||
data['userId'] as String? ??
|
||||
(data['account'] as Map<String, dynamic>?)?['id'] as String? ??
|
||||
'';
|
||||
String get pendingToken =>
|
||||
(data['pendingToken'] as Map<String, dynamic>)['token'] as String;
|
||||
String get target => data['target'] as String;
|
||||
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
||||
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
|
||||
String get accessToken =>
|
||||
(data['accessToken'] as Map<String, dynamic>)['token'] as String;
|
||||
String get refreshToken =>
|
||||
(data['refreshToken'] as Map<String, dynamic>)['token'] as String;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import '../dto/backend.dart';
|
||||
import '../models/auth.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/two_factor.dart';
|
||||
|
||||
abstract final class BackendMapper {
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||
}
|
||||
|
||||
static FactorChallenge challenge(BackendDTO dto, String id) =>
|
||||
FactorChallenge(
|
||||
id: id,
|
||||
purpose: FactorPurpose.login,
|
||||
operation: id,
|
||||
target: dto.target,
|
||||
expiresAt: dto.expiresAt,
|
||||
resendAt: dto.resendAt,
|
||||
);
|
||||
static AuthSession session(BackendDTO dto) {
|
||||
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
|
||||
throw const FormatException('Missing session');
|
||||
}
|
||||
return AuthSession(dto.userId, dto.accessToken, dto.refreshToken);
|
||||
}
|
||||
|
||||
static AuthException authFailure(int status, BackendDTO dto) =>
|
||||
AuthException(switch (dto.error) {
|
||||
'email_in_use' => AuthError.emailInUse,
|
||||
'account_not_verified' => AuthError.accountNotVerified,
|
||||
_ when status == 401 => AuthError.invalidCredentials,
|
||||
_ when status == 429 => AuthError.factorCooldown,
|
||||
_ => AuthError.unavailable,
|
||||
});
|
||||
static FactorException factorFailure(int status, BackendDTO dto) =>
|
||||
FactorException(switch (dto.error) {
|
||||
'expired' => FactorError.expired,
|
||||
'locked' => FactorError.locked,
|
||||
'invalid' => FactorError.invalid,
|
||||
_ when status == 429 => FactorError.cooldown,
|
||||
_ when status == 401 => FactorError.expired,
|
||||
_ => FactorError.unavailable,
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class AuthResult {
|
||||
|
||||
enum AuthError {
|
||||
invalidCredentials,
|
||||
accountNotVerified,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// In-memory credentials. Never include these in analytics or provider state.
|
||||
class AuthSession {
|
||||
const AuthSession(this.userId, this.accessToken, this.refreshToken);
|
||||
final String userId, accessToken, refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../dto/backend.dart';
|
||||
import '../dto/results.dart';
|
||||
import '../mappers/backend.dart';
|
||||
import '../mappers/results.dart';
|
||||
import '../models/auth.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/two_factor.dart';
|
||||
import '../models/verification.dart';
|
||||
import 'contracts.dart';
|
||||
import 'guarded_login.dart';
|
||||
import 'http/client.dart';
|
||||
import 'two_factor.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Real authentication; wallet/settings operations remain separate adapters.
|
||||
class BackendAuthServices
|
||||
implements
|
||||
PendingLoginService,
|
||||
SignupService,
|
||||
VerificationService,
|
||||
TwoFactorService,
|
||||
SimulationInfo {
|
||||
BackendAuthServices({
|
||||
required this.baseUri,
|
||||
required this.clientId,
|
||||
http.Client? client,
|
||||
}) : _client = client ?? createClient() {
|
||||
if (clientId.isEmpty ||
|
||||
!baseUri.hasAuthority ||
|
||||
(baseUri.scheme != 'https' &&
|
||||
!(baseUri.scheme == 'http' &&
|
||||
['localhost', '127.0.0.1'].contains(baseUri.host))) ||
|
||||
baseUri.hasQuery ||
|
||||
baseUri.hasFragment ||
|
||||
baseUri.userInfo.isNotEmpty) {
|
||||
throw ArgumentError('Valid API URL and client ID required');
|
||||
}
|
||||
}
|
||||
final Uri baseUri;
|
||||
final String clientId;
|
||||
final http.Client _client;
|
||||
final String _deviceId = _key();
|
||||
final _pending = <String, String>{};
|
||||
final _grants = <String, AuthSession>{};
|
||||
final _retryKeys = <String, String>{};
|
||||
String? _queuedSignup;
|
||||
AuthSession? _session;
|
||||
AuthSession? get session => _session;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
static String _key() => base64UrlEncode(
|
||||
List<int>.generate(24, (_) => Random.secure().nextInt(256)),
|
||||
);
|
||||
Future<BackendDTO> _post(
|
||||
String path,
|
||||
Map<String, Object?> body, {
|
||||
bool factor = false,
|
||||
}) async {
|
||||
try {
|
||||
final uri = baseUri.replace(
|
||||
path: '${baseUri.path.replaceFirst(RegExp(r'/$'), '')}/accounts/$path',
|
||||
);
|
||||
final res = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (kIsWeb) 'X-Auth-Transport': 'cookie',
|
||||
if (kIsWeb) 'X-CSRF-Protection': '1',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final dto = BackendDTO.decode(res.body);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw factor
|
||||
? BackendMapper.factorFailure(res.statusCode, dto)
|
||||
: BackendMapper.authFailure(res.statusCode, dto);
|
||||
}
|
||||
return dto;
|
||||
} on AuthException {
|
||||
rethrow;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
if (factor) throw const FactorException(FactorError.unavailable);
|
||||
throw const AuthException(AuthError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest r) async {
|
||||
final email = r.email.trim().toLowerCase();
|
||||
final key = _retryKeys.putIfAbsent('signup:$email', _key);
|
||||
final dto = await _post('signup', {
|
||||
'name': r.name,
|
||||
'email': email,
|
||||
'password': r.password,
|
||||
'locale': r.locale,
|
||||
'idempotencyKey': key,
|
||||
});
|
||||
final result = BackendMapper.signup(dto);
|
||||
_retryKeys.remove('signup:$email');
|
||||
_queuedSignup = email;
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest r) async {
|
||||
_pending.clear();
|
||||
_grants.clear();
|
||||
_session = null;
|
||||
final dto = await _post('login', {
|
||||
'login': {'login': r.email, 'password': r.password},
|
||||
'clientId': clientId,
|
||||
'deviceId': _deviceId,
|
||||
});
|
||||
final id = _key();
|
||||
final challenge = BackendMapper.challenge(dto, id);
|
||||
_pending[id] = dto.pendingToken;
|
||||
return AuthResult(userId: '', isSimulated: false, challenge: challenge);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
// Signup already queued this email. The existing confirmation page requests
|
||||
// its initial delivery on mount; consume that receipt once.
|
||||
if (_queuedSignup == normalized) {
|
||||
_queuedSignup = null;
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
}
|
||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||
try {
|
||||
await _post('verification/send', {
|
||||
'email': normalized,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
|
||||
_retryKeys.remove('activation:$normalized');
|
||||
}
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
} catch (_) {
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
try {
|
||||
await _post('verification/confirm', {'token': token}, factor: true);
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
} on FactorException catch (e) {
|
||||
throw VerificationException(switch (e.code) {
|
||||
FactorError.expired => VerificationError.expired,
|
||||
FactorError.invalid => VerificationError.invalid,
|
||||
_ => VerificationError.internal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorChallenge> resend(String challengeId) async {
|
||||
final pending = _pending[challengeId];
|
||||
if (pending == null) throw const FactorException(FactorError.expired);
|
||||
final key = _retryKeys.putIfAbsent('login:$challengeId', _key);
|
||||
final dto = await _post('login/resend', {
|
||||
'pendingToken': pending,
|
||||
'idempotencyKey': key,
|
||||
}, factor: true);
|
||||
_retryKeys.remove('login:$challengeId');
|
||||
return BackendMapper.challenge(dto, challengeId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorGrant> verify(String challengeId, String code) async {
|
||||
final pending = _pending[challengeId];
|
||||
if (pending == null) throw const FactorException(FactorError.expired);
|
||||
final dto = await _post('login/verify', {
|
||||
'pendingToken': pending,
|
||||
'code': code,
|
||||
}, factor: true);
|
||||
final session = BackendMapper.session(dto);
|
||||
_pending.remove(challengeId);
|
||||
_grants[challengeId] = session;
|
||||
return FactorGrant(challengeId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> completeLogin(
|
||||
FactorChallenge challenge,
|
||||
FactorGrant grant,
|
||||
) async {
|
||||
if (challenge.id != grant.token ||
|
||||
challenge.purpose != FactorPurpose.login) {
|
||||
throw const AuthException(AuthError.factorRequired);
|
||||
}
|
||||
final accepted = _grants.remove(grant.token);
|
||||
if (accepted == null) throw const AuthException(AuthError.factorRequired);
|
||||
_session = accepted;
|
||||
return AuthResult(userId: accepted.userId, isSimulated: false);
|
||||
}
|
||||
|
||||
// Unsupported settings and money operations fail closed until their own
|
||||
// backend adapters exist. They cannot borrow an authentication grant.
|
||||
@override
|
||||
Future<FactorSettings> fetch() async =>
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
@override
|
||||
Future<FactorChallenge?> begin(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
) async => throw const FactorException(FactorError.unavailable);
|
||||
@override
|
||||
Future<void> authorize(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
String? grant,
|
||||
) async => throw const FactorException(FactorError.required);
|
||||
@override
|
||||
Future<FactorSettings> setEnabled(bool enabled, String grant) async =>
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
void close() {
|
||||
_client.close();
|
||||
_pending.clear();
|
||||
_grants.clear();
|
||||
_retryKeys.clear();
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => BrowserClient()..withCredentials = true;
|
||||
@@ -0,0 +1 @@
|
||||
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => http.Client();
|
||||
@@ -56,6 +56,7 @@ class InstrumentedAuthServices
|
||||
} catch (error) {
|
||||
final failure = switch (error) {
|
||||
AuthException(code: final code) => switch (code) {
|
||||
AuthError.accountNotVerified => JourneyFailure.invalid,
|
||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||
AuthError.unavailable => JourneyFailure.unavailable,
|
||||
|
||||
@@ -20,3 +20,6 @@ export 'src/services/two_factor.dart';
|
||||
export 'src/services/dummy_factor.dart';
|
||||
export 'src/widgets/two_factor.dart';
|
||||
export 'src/services/guarded_login.dart';
|
||||
|
||||
export 'src/services/backend.dart';
|
||||
export 'src/models/session.dart';
|
||||
@@ -6,6 +6,7 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
http: ^1.6.0
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
flutter:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
void main() {
|
||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||
http.Response(jsonEncode({'data': data}), status);
|
||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
if (request.url.path.endsWith('/signup')) {
|
||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
||||
return response({'userId': 'user'});
|
||||
}
|
||||
return response({'accepted': true});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.signup(
|
||||
const SignupRequest(
|
||||
name: 'User',
|
||||
email: 'USER@example.com',
|
||||
password: ' password preserved ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
expect(result.isSimulated, isFalse);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths, ['/api/v1/accounts/signup']);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||
});
|
||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||
final now = DateTime.now();
|
||||
var verified = false;
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
final body = jsonDecode(request.body);
|
||||
if (request.url.path.endsWith('/login')) {
|
||||
expect(body['login'], {
|
||||
'login': 'user@example.com',
|
||||
'password': ' password ',
|
||||
});
|
||||
expect(body['clientId'], 'web');
|
||||
return response({
|
||||
'userId': 'user',
|
||||
'pendingToken': {'token': 'signed-pending'},
|
||||
'target': 'u***@example.com',
|
||||
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
||||
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
|
||||
});
|
||||
}
|
||||
expect(request.url.path, '/api/v1/accounts/login/verify');
|
||||
expect(body['pendingToken'], 'signed-pending');
|
||||
if (body['code'] != '123456')
|
||||
return response({'error': 'invalid'}, 400);
|
||||
verified = true;
|
||||
return response({
|
||||
'account': {'id': 'user'},
|
||||
'accessToken': {'token': 'access'},
|
||||
'refreshToken': {'token': 'refresh'},
|
||||
}, 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.login(
|
||||
const LoginRequest(
|
||||
email: 'user@example.com',
|
||||
password: ' password ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
expect(result.challenge, isNotNull);
|
||||
expect(service.session, isNull);
|
||||
final challenge = result.challenge!;
|
||||
expect(challenge.id, isNot('signed-pending'));
|
||||
await expectLater(
|
||||
service.verify(challenge.id, '000000'),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
expect(verified, isFalse);
|
||||
expect(service.session, isNull);
|
||||
final grant = await service.verify(challenge.id, '123456');
|
||||
final accepted = await service.completeLogin(challenge, grant);
|
||||
expect(accepted.userId, 'user');
|
||||
expect(accepted.isSimulated, isFalse);
|
||||
expect(service.session!.accessToken, 'access');
|
||||
await expectLater(
|
||||
service.completeLogin(challenge, grant),
|
||||
throwsA(isA<AuthException>()),
|
||||
);
|
||||
await expectLater(
|
||||
service.authorize(FactorPurpose.payout, 'payment', grant.token),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'failed delivery preserves resend idempotency and never claims success',
|
||||
() async {
|
||||
final keys = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
||||
return response({'error': 'unavailable'}, 503);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await expectLater(
|
||||
service.send(email: 'user@example.com', locale: 'en'),
|
||||
throwsA(isA<VerificationException>()),
|
||||
);
|
||||
}
|
||||
expect(keys[0], keys[1]);
|
||||
},
|
||||
);
|
||||
test('expired activation maps to recovery state', () async {
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((_) async => response({'error': 'expired'}, 400)),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
await expectLater(
|
||||
service.confirm(token: 'expired'),
|
||||
throwsA(
|
||||
isA<VerificationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
VerificationError.expired,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user