Compare commits
1
Commits
ce6583f454
..
bff-34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c822e2ed7b |
No files matched your search
@@ -1,16 +0,0 @@
|
||||
.git
|
||||
.local
|
||||
**/.dart_tool
|
||||
**/build
|
||||
**/.env*
|
||||
**/node_modules
|
||||
**/*.pb.go
|
||||
frontend/app/lib/generated
|
||||
**/.DS_Store
|
||||
dist
|
||||
website
|
||||
**/config.yml
|
||||
**/tls
|
||||
**/*.pem
|
||||
**/*.key
|
||||
**/__pycache__
|
||||
@@ -34,9 +34,3 @@ pnpm-debug.log*
|
||||
/backend/notifications/interface/notifications/**/*.pb.go
|
||||
/backend/notifications/config.yml
|
||||
/backend/notifications/notifications
|
||||
|
||||
# Local container pins, Vault bootstrap material and service identities
|
||||
/.local/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -8,36 +8,6 @@
|
||||
- For cross-module work, apply every relevant scoped instruction file and
|
||||
completion workflow.
|
||||
|
||||
## Autogenerated code
|
||||
|
||||
- Never commit autogenerated code to the repository. This applies to backend,
|
||||
frontend, shared packages, and every other module, including generated
|
||||
protobuf bindings, API clients, serializers, and framework-generated code.
|
||||
- Commit the source definitions, generator configuration, and reproducible
|
||||
generation commands instead. Generate code locally or during build and CI
|
||||
workflows before any dependent validation or compilation steps.
|
||||
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||
files or copy generated code into tracked source files.
|
||||
- Change the source definitions or generator inputs rather than editing
|
||||
generated outputs. Document the generation workflow so a clean checkout can
|
||||
build without committed generated code.
|
||||
|
||||
## Local development tooling
|
||||
|
||||
Use Make and shell for local development orchestration. Do not introduce Python
|
||||
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
|
||||
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
|
||||
|
||||
## Container versions
|
||||
|
||||
Use only the latest stable upstream releases for infrastructure, product base
|
||||
images, build stages and disposable test containers. Exclude prereleases and
|
||||
vendor mainline channels when a separate stable channel exists. Resolve stable
|
||||
versions when building, pull fresh manifests, and pin the resolved image digests
|
||||
for that local build. Fail when stable release resolution fails; never silently
|
||||
fall back to an older cached version. Keep the reproducible resolver tracked and
|
||||
its generated local pins ignored by Git.
|
||||
|
||||
## Data and state architecture
|
||||
|
||||
Preserve explicit **DTO → mapper → service → data model → provider → UI**
|
||||
@@ -169,18 +139,3 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
|
||||
into task commits. Record scope, validation, limitations, and PR links in the
|
||||
task and durable repository documentation. Merge only when authorized by the
|
||||
user. If work is delivered as stacked PRs, document dependencies and merge order.
|
||||
|
||||
## Local infrastructure ownership
|
||||
|
||||
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
|
||||
configuration and shell tooling under `infra/local/`. Keep product source under
|
||||
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
|
||||
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
|
||||
and bootstrap administration under `infrastructure/<component>/bootstrap`.
|
||||
Do not add BFF-to-notifications caller authentication on the trusted private network.
|
||||
Preserve NATS authentication.
|
||||
Use Caddy for local browser HTTPS and certificate management. Treat the private
|
||||
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
|
||||
local TLS certificates without an explicit requirement.
|
||||
Store only secret values in Vault. Driver selection, sender identities, endpoints
|
||||
and other non-secret runtime settings belong in service configuration files.
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -17,12 +16,10 @@ test-frontend:
|
||||
.PHONY: test-backend lint-backend run-backend
|
||||
|
||||
test-backend: generate-notifications
|
||||
docker pull mongo:latest
|
||||
docker pull nats:latest
|
||||
cd backend/shared && go test -race -count=1 ./...
|
||||
cd backend/notifications/interface && go test -race -count=1 ./...
|
||||
cd backend/notifications && go test -race -count=1 ./...
|
||||
cd backend/product && go test -race -count=1 ./...
|
||||
cd backend/shared && go test -race ./...
|
||||
cd backend/notifications/interface && go test -race ./...
|
||||
cd backend/notifications && go test -race ./...
|
||||
cd backend/product && go test -race ./...
|
||||
|
||||
lint-backend: generate-notifications
|
||||
cd backend/product && go test ./architecture
|
||||
@@ -51,44 +48,3 @@ package-notifications-interface: generate-notifications
|
||||
mkdir -p dist
|
||||
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
||||
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
|
||||
|
||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||
.PHONY: build up down logs ps test-dev
|
||||
build:
|
||||
sh infra/local/tools.sh build
|
||||
sh infra/local/tools.sh images
|
||||
$(COMPOSE) pull vault mongo
|
||||
$(COMPOSE) build --pull
|
||||
|
||||
up:
|
||||
sh infra/local/tools.sh up
|
||||
|
||||
down:
|
||||
$(COMPOSE) down
|
||||
|
||||
logs:
|
||||
$(COMPOSE) logs --tail=100 -f
|
||||
|
||||
ps:
|
||||
$(COMPOSE) ps
|
||||
|
||||
test-dev:
|
||||
sh infra/local/tools.sh test
|
||||
sh infra/local/tools.sh verify
|
||||
|
||||
.PHONY: test-dev-infra
|
||||
test-dev-infra:
|
||||
sh infra/local/tools.sh test
|
||||
sh infra/local/tools.sh verify-infra
|
||||
|
||||
.PHONY: test-dev-rotation
|
||||
test-dev-rotation:
|
||||
sh infra/local/tools.sh rotation
|
||||
|
||||
.PHONY: email test-dev-tooling
|
||||
email:
|
||||
sh infra/local/tools.sh email
|
||||
|
||||
test-dev-tooling:
|
||||
sh infra/local/tools.sh test
|
||||
@@ -32,24 +32,6 @@ Each application owns its dependencies and build configuration. Shared Dart and
|
||||
Flutter packages can be reused by a future mobile app; mobile runners are not
|
||||
currently included. Local `.vscode/` configuration is ignored.
|
||||
|
||||
## Local Docker development
|
||||
|
||||
```sh
|
||||
make build && make up
|
||||
```
|
||||
|
||||
This builds the product images and starts an isolated Vault, authenticated MongoDB
|
||||
replica set and NATS JetStream. First startup intentionally stops after bootstrap
|
||||
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
|
||||
and the verified sender in `infra/local/notifications/runtime.yml`.
|
||||
Then rerun `make up` to start notifications, BFF and the frontend at
|
||||
[https://localhost:5126](https://localhost:5126).
|
||||
|
||||
All image inputs resolve to current stable releases and are pinned by digest for
|
||||
the build. Services fetch secrets from Vault; Mongo application credentials come
|
||||
from Vault's database plugin. See the [local development guide](docs/local-development.md)
|
||||
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
|
||||
|
||||
## Run the consumer app
|
||||
|
||||
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
|
||||
@@ -72,8 +54,7 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
|
||||
repository root:
|
||||
|
||||
```sh
|
||||
docker run --rm --pull always -p 127.0.0.1:5123:80 \
|
||||
-v "$PWD/frontend/app/build/web:/srv:ro" caddy:alpine caddy file-server --root /srv --listen :80
|
||||
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web
|
||||
```
|
||||
|
||||
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
|
||||
|
||||
@@ -71,11 +71,3 @@ code. Auth and verification configurations moved out of application interfaces.
|
||||
All 112 unconsumed public API snapshot files and their snapshot-only check were
|
||||
removed. Future public adapters will own just the wire types they consume.
|
||||
Backoffice is a separate future executable module, not an alias for product.
|
||||
|
||||
## Second-factor separation (#40)
|
||||
|
||||
The new `product/twofactor` domain service reuses the already copied
|
||||
`product/verification/store` issuance and consumption implementation at the pinned
|
||||
Sendico revision. No additional Sendico source files were copied. Coupled
|
||||
verification HTTP/session/private-messaging handlers were inspected but excluded.
|
||||
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
|
||||
@@ -22,12 +22,12 @@ subject, text and HTML, bounded timeouts and safe error classification.
|
||||
|
||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
|
||||
variable. Configure a verified sender address and template
|
||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for
|
||||
each configured caller, and the selected email provider's API key environment
|
||||
variable. Configure a verified sender address, TLS certificate/key and template
|
||||
catalog. Paths are relative to the process working directory.
|
||||
|
||||
From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
|
||||
`tls_cert` and `tls_key` only when TLS is explicitly required.
|
||||
From the root, run `make run-notifications`. The gRPC listener always uses TLS.
|
||||
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
|
||||
are supported by the shared driver. Mongo and messaging both use shared
|
||||
`DriverConfig` settings; email settings reject unknown keys.
|
||||
@@ -46,8 +46,7 @@ Multiple service workers can safely compete for jobs through atomic claims.
|
||||
|
||||
## Templates and parameters
|
||||
|
||||
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
|
||||
See `templates.example.json`. 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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
@@ -21,6 +22,7 @@ type Config struct {
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
Database db.Config `yaml:"database"`
|
||||
Templates string `yaml:"templates"`
|
||||
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
|
||||
Email email.Config `yaml:"email"`
|
||||
}
|
||||
|
||||
@@ -38,14 +40,19 @@ func Load(path string) (Config, error) {
|
||||
if err = decoder.Decode(new(any)); err != io.EOF {
|
||||
return c, merrors.InvalidArgument("unexpected configuration content")
|
||||
}
|
||||
if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
|
||||
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 {
|
||||
return c, merrors.InvalidArgument("missing notification configuration")
|
||||
}
|
||||
if (c.TLSCert == "") != (c.TLSKey == "") {
|
||||
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
|
||||
}
|
||||
if err = c.Queue.Validate(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for caller, env := range c.Callers {
|
||||
token := os.Getenv(env)
|
||||
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
|
||||
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -14,6 +16,11 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
|
||||
t.Fatal("wrong driver configuration")
|
||||
}
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
|
||||
if _, err = Load("../config.example.yml"); err == nil {
|
||||
t.Fatal("missing credentials accepted")
|
||||
}
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -26,30 +33,3 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
t.Fatal("extra YAML document accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := serverOptions(c)
|
||||
if err != nil || len(options) != 0 {
|
||||
t.Fatal("plain gRPC must not require TLS material")
|
||||
}
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("incomplete TLS configuration accepted")
|
||||
}
|
||||
}
|
||||
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
|
||||
t.Fatal("bad explicit certificate accepted")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -32,9 +33,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := serverOptions(c)
|
||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||
if err != nil {
|
||||
return err
|
||||
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||
}
|
||||
client, err := db.ConnectMongo(logger, &c.Database)
|
||||
if err != nil {
|
||||
@@ -62,7 +63,11 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer broker.Close()
|
||||
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
|
||||
tokens := map[string]string{}
|
||||
for caller, env := range c.Callers {
|
||||
tokens[caller] = os.Getenv(env)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
|
||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||
listener, err := net.Listen("tcp", c.Listen)
|
||||
if err != nil {
|
||||
@@ -98,15 +103,3 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
logger.Info("Notifications service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// An omitted certificate pair uses plaintext gRPC on the private service network.
|
||||
func serverOptions(c Config) ([]grpc.ServerOption, error) {
|
||||
if c.TLSCert == "" && c.TLSKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||
}
|
||||
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
listen: "127.0.0.1:8083"
|
||||
tls_cert: "server.crt"
|
||||
tls_key: "server.key"
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
@@ -12,6 +14,8 @@ messaging:
|
||||
max_reconnects: -1
|
||||
reconnect_wait: 2
|
||||
templates: "templates.example.json"
|
||||
callers:
|
||||
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
|
||||
email:
|
||||
driver: resend # sendgrid or resend
|
||||
settings:
|
||||
|
||||
@@ -20,14 +20,14 @@ Internal Go consumers use the module
|
||||
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
|
||||
checkout, use a local replace to this directory and generate before building.
|
||||
|
||||
Calls use the trusted private network without caller authentication. The service
|
||||
uses the existing product storage namespace. Callers authorize user/destination
|
||||
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service
|
||||
maps tokens to configured caller identities. Callers authorize user/destination
|
||||
ownership and preferences before sending; this contract is not a public user API.
|
||||
|
||||
Parameters are string values keyed by template parameter name. The entire request
|
||||
is validated before persistence. An identical idempotency key/request returns the
|
||||
original resource, even after completion or expiry. A changed request conflicts.
|
||||
Keys are scoped to the product namespace and retained at least seven days;
|
||||
Keys are scoped to the authenticated caller and retained at least seven days;
|
||||
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
|
||||
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
|
||||
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Internal service on the trusted private network; no caller authentication. Send durably queues work.
|
||||
// Internal service: authenticate callers over TLS. Send durably queues work.
|
||||
service NotificationsService {
|
||||
rpc Send(SendRequest) returns (SendResponse);
|
||||
rpc Get(GetRequest) returns (GetResponse);
|
||||
}
|
||||
message SendRequest {
|
||||
string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
|
||||
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days.
|
||||
string user_id = 2; // Caller resolves and authorizes recipient ownership.
|
||||
string template_id = 3;
|
||||
string template_version = 4;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -16,9 +17,10 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/notifications/transport"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Exercise optional TLS transport against the real service and storage.
|
||||
// Exercise authenticated TLS transport against the real service and storage.
|
||||
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -40,7 +42,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"})))
|
||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
@@ -51,3 +53,6 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return pb.NewNotificationsServiceClient(conn)
|
||||
}
|
||||
func callerContext(ctx context.Context, caller string) context.Context {
|
||||
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type sender struct {
|
||||
@@ -48,7 +50,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
|
||||
func TestDurableDelivery(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest")
|
||||
container, err := mongodb.Run(ctx, "mongo:8.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -190,7 +192,7 @@ func TestDurableDelivery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Verify the real JetStream publisher/consumer path with an isolated broker.
|
||||
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
||||
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -231,8 +233,11 @@ func TestDurableDelivery(t *testing.T) {
|
||||
}
|
||||
|
||||
client := grpcClient(t, svc)
|
||||
rpcCtx := ctx
|
||||
rpcCtx := callerContext(ctx, "product")
|
||||
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
|
||||
t.Fatal("unauthenticated request accepted", e)
|
||||
}
|
||||
sent, err := client.Send(rpcCtx, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -241,8 +246,8 @@ func TestDurableDelivery(t *testing.T) {
|
||||
if err != nil || replay.Notification.Id != sent.Notification.Id {
|
||||
t.Fatal("RPC idempotency failed", err)
|
||||
}
|
||||
if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
|
||||
t.Fatal("RPC retrieval without credentials failed", e)
|
||||
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound {
|
||||
t.Fatal("RPC caller isolation failed", e)
|
||||
}
|
||||
if err = broker.PublishDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ type Store struct {
|
||||
template.DBImp[*model.Notification]
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, db source.Source) (*Store, error) {
|
||||
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) {
|
||||
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
|
||||
ttl := int32(0)
|
||||
for _, index := range []*ri.Definition{
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
"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",
|
||||
@@ -15,65 +11,5 @@
|
||||
"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>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "twofactor.code",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE verification code",
|
||||
"text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.",
|
||||
"html": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код подтверждения SXCHNGE",
|
||||
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,15 +2,21 @@ package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type callerKey struct{}
|
||||
type API interface {
|
||||
Send(context.Context, string, model.Request) (*model.Notification, error)
|
||||
Get(context.Context, string, string) (*model.Notification, error)
|
||||
@@ -20,14 +26,14 @@ type Server struct {
|
||||
service API
|
||||
}
|
||||
|
||||
// The private endpoint uses the existing product storage namespace without caller authentication.
|
||||
func New(service API) *Server { return &Server{service: service} }
|
||||
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
|
||||
input, err := request(r)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
n, err := s.service.Send(ctx, "product", input)
|
||||
caller, _ := ctx.Value(callerKey{}).(string)
|
||||
n, err := s.service.Send(ctx, caller, input)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
@@ -37,13 +43,39 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
|
||||
if r == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing request")
|
||||
}
|
||||
n, err := s.service.Get(ctx, "product", r.Id)
|
||||
caller, _ := ctx.Value(callerKey{}).(string)
|
||||
n, err := s.service.Get(ctx, caller, r.Id)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &pb.GetResponse{Notification: response(n)}, nil
|
||||
}
|
||||
|
||||
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
|
||||
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
|
||||
hashes := map[string][32]byte{}
|
||||
for caller, token := range tokens {
|
||||
hashes[caller] = sha256.Sum256([]byte(token))
|
||||
}
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
values := md.Get("authorization")
|
||||
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
|
||||
identity := ""
|
||||
for caller, expected := range hashes {
|
||||
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
|
||||
identity = caller
|
||||
}
|
||||
}
|
||||
if identity == "" {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
||||
}
|
||||
return handler(context.WithValue(ctx, callerKey{}, identity), req)
|
||||
}
|
||||
}
|
||||
func rpcError(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return status.Error(codes.Canceled, "notification operation canceled")
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
@@ -30,9 +31,10 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
|
||||
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
||||
return nil, merrors.NoData("private details")
|
||||
}
|
||||
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
||||
func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
||||
listener := bufconn.Listen(1 << 20)
|
||||
server := grpc.NewServer()
|
||||
token := strings.Repeat("a", 32)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
|
||||
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer server.Stop()
|
||||
@@ -43,7 +45,10 @@ func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
||||
defer conn.Close()
|
||||
client := pb.NewNotificationsServiceClient(conn)
|
||||
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||
ctx := context.Background()
|
||||
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
|
||||
result, err := client.Send(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -24,11 +24,8 @@ 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 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.
|
||||
Signup/profile HTTP wiring and verification delivery/completion remain follow-up
|
||||
work. The frontend still uses dummy services.
|
||||
|
||||
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,12 +2,10 @@ 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 {
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
|
||||
@@ -7,18 +7,10 @@ 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"
|
||||
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -34,7 +26,6 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
@@ -56,9 +47,6 @@ 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) {
|
||||
@@ -119,7 +107,6 @@ 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")
|
||||
@@ -152,50 +139,15 @@ func CreateMiddleware(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
var factors *twofactor.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
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
|
||||
records, err := factorstore.Open(p.logger, db)
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications})
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
flows := public.Flows{Onboarding: authFlow}
|
||||
if factors != nil {
|
||||
flows.Factors = factors
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.Verification,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
flows,
|
||||
)
|
||||
if factors != nil {
|
||||
factorhttp.Install(p.epdispatcher, factors, p.logger)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -83,7 +83,6 @@ 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,6 +2,7 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -13,7 +14,6 @@ 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"
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
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)
|
||||
@@ -48,7 +48,6 @@ 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")
|
||||
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)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Keep the reusable domain independent of transport and session ownership.
|
||||
func TestSecondFactorDomainBoundary(t *testing.T) {
|
||||
err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == "http" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range file.Imports {
|
||||
name, err := strconv.Unquote(im.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} {
|
||||
if strings.Contains(name, blocked) {
|
||||
t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Package flow owns signup and account activation, independently of second-factor checks.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"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
|
||||
|
||||
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 (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 := customer.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.sendActivation(ctx, a, key)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||
email, err := customer.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.sendActivation(ctx, a, key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !result.ExpiresAt.After(time.Now()) {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", result.VerificationToken)
|
||||
u.RawQuery = q.Encode()
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
||||
return result.ExpiresAt, err
|
||||
}
|
||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||
if len(raw) != 43 {
|
||||
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
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
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)), ActivationURL: "https://app.example/verify-email"}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = "user@example.com"
|
||||
a.Locale = "ru-RU"
|
||||
_, err := service.sendActivation(context.Background(), a, "client-key")
|
||||
require.Error(t, err)
|
||||
key := *tokens.request.IdempotencyKey
|
||||
require.NotEqual(t, "client-key", key)
|
||||
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
|
||||
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
|
||||
sender.err = nil
|
||||
_, err = service.sendActivation(context.Background(), a, "client-key")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.sendActivation(context.Background(), a, "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,17 +3,19 @@ package router
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
@@ -38,11 +40,11 @@ func NewDispatcher(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
@@ -52,7 +54,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, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
|
||||
@@ -5,6 +5,8 @@ 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"
|
||||
|
||||
@@ -12,8 +14,6 @@ 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)
|
||||
// Preserve passwords exactly as entered.
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||
@@ -225,9 +225,6 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||
return h
|
||||
}
|
||||
if a.factors != nil {
|
||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||
}
|
||||
return a.respondPendingLogin(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
|
||||
@@ -19,14 +19,6 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
session *sessionmodel.SessionIdentifier,
|
||||
account *customermodel.Account,
|
||||
) http.HandlerFunc {
|
||||
result, err := pr.prepareLogin(ctx, r, session, account)
|
||||
if err != nil {
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
|
||||
refreshToken, err := rtokens.PrepareRefreshToken(
|
||||
ctx,
|
||||
r,
|
||||
@@ -40,18 +32,18 @@ func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, sessi
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return nil, err
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return nil, err
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
|
||||
token := sresponse.TokenData{
|
||||
Token: refreshToken.RefreshToken,
|
||||
Expiration: refreshToken.ExpiresAt,
|
||||
}
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
||||
}
|
||||
@@ -4,12 +4,11 @@ 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"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
@@ -17,14 +16,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Flows struct {
|
||||
Onboarding *flow.Service
|
||||
Factors twofactor.API
|
||||
}
|
||||
|
||||
type PublicRouter struct {
|
||||
factors twofactor.API
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
@@ -45,13 +37,13 @@ func NewRouter(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
router chi.Router,
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...Flows,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
@@ -67,20 +59,7 @@ func NewRouter(
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
if len(flows) > 0 {
|
||||
hr.flow = flows[0].Onboarding
|
||||
hr.factors = flows[0].Factors
|
||||
}
|
||||
if hr.flow != nil {
|
||||
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
|
||||
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
||||
}
|
||||
if hr.factors != nil {
|
||||
hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin))
|
||||
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
||||
}
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, measured("login", hr.login))
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, 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)
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"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 {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Channel factor.Channel `json:"channel"`
|
||||
UserID string `json:"userId"`
|
||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
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 loginScope(raw string) factor.Scope {
|
||||
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
|
||||
}
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
|
||||
var challenge factor.Challenge
|
||||
var err error
|
||||
if id == "" {
|
||||
challenge, err = pr.factors.Begin(r.Context(), req)
|
||||
} else {
|
||||
challenge, err = pr.factors.Resend(r.Context(), req, id)
|
||||
}
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
|
||||
}
|
||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||
if len(raw) > 8192 {
|
||||
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)
|
||||
}
|
||||
if req.ChallengeID == "" {
|
||||
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
|
||||
}
|
||||
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
|
||||
}
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
scope := loginScope(req.PendingToken)
|
||||
var result http.HandlerFunc
|
||||
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
|
||||
var err error
|
||||
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
||||
return result
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"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:latest", 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"}}
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
require.NoError(t, err)
|
||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||
require.NoError(t, err)
|
||||
routes := chi.NewRouter()
|
||||
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
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, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code})
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
require.Equal(t, 503, post("login", login).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
}
|
||||
@@ -31,6 +31,3 @@ api:
|
||||
token_length: 32
|
||||
check:
|
||||
min_length: 12
|
||||
notifications:
|
||||
address: localhost:8083
|
||||
activation_url: http://localhost:5126/verify-email
|
||||
@@ -2,10 +2,6 @@ package accountserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
@@ -19,6 +15,9 @@ import (
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func customer(login string) *customermodel.Account {
|
||||
@@ -30,7 +29,7 @@ func customer(login string) *customermodel.Account {
|
||||
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
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)
|
||||
@@ -87,10 +86,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
|
||||
deps, err := edgemongo.ResolveDependencies(factory)
|
||||
require.NoError(t, err)
|
||||
raw, rawContext, release, err := deps.Database.Acquire(ctx)
|
||||
require.NoError(t, err)
|
||||
defer release()
|
||||
collections, err := raw.ListCollectionNames(rawContext, bson.D{})
|
||||
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
|
||||
require.NoError(t, err)
|
||||
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
|
||||
require.NotContains(t, collections, forbidden)
|
||||
@@ -120,7 +116,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
require.ErrorIs(t, err, outerFailure)
|
||||
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
|
||||
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, count)
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeEmail(email string) (string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
a, err := mail.ParseAddress(email)
|
||||
if err != nil || a.Address != email || len(email) > 254 {
|
||||
return "", merrors.InvalidArgument("valid email required")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package accountdb
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"strings"
|
||||
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -16,11 +16,11 @@ type AccountDB struct {
|
||||
template.DBImp[*customermodel.Account]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) {
|
||||
return CreateWithCollection(logger, db, mservice.Accounts)
|
||||
}
|
||||
|
||||
func CreateWithCollection(logger mlogger.Logger, db source.Source, collection string) (*AccountDB, error) {
|
||||
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) {
|
||||
if normalized := strings.TrimSpace(collection); normalized != "" {
|
||||
collection = normalized
|
||||
} else {
|
||||
|
||||
@@ -69,17 +69,13 @@ 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/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/protobuf v1.36.11 // 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
|
||||
@@ -93,10 +89,8 @@ 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/notifications/interface => ../notifications/interface
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
+2
-12
@@ -63,8 +63,6 @@ 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=
|
||||
@@ -228,8 +226,6 @@ 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=
|
||||
@@ -271,14 +267,8 @@ 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=
|
||||
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=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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=
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// 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/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string `yaml:"address"`
|
||||
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
|
||||
}
|
||||
|
||||
func Open(c Config) (*Client, error) {
|
||||
if c.Address == "" {
|
||||
return nil, merrors.InvalidArgument("notification address is 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
|
||||
}
|
||||
var transport credentials.TransportCredentials = insecure.NewCredentials()
|
||||
if c.CAFile != "" || c.ServerName != "" {
|
||||
transport = credentials.NewTLS(tlsConfig)
|
||||
}
|
||||
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(transport))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create notification client")
|
||||
}
|
||||
return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn)}, 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()
|
||||
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}}}}}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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")
|
||||
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}
|
||||
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.Empty(t, 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 TestOpenRequiresAddressAndValidCA(t *testing.T) {
|
||||
_, err := Open(Config{})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
_, err = Open(Config{Address: "localhost:8443", 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")
|
||||
return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil
|
||||
}
|
||||
func TestOpenUsesVerifiedTLSWithoutCallerToken(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))
|
||||
client, err := Open(Config{Address: listener.Addr().String(), 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.Empty(t, <-rpc.auth)
|
||||
}
|
||||
|
||||
func TestOpenUsesPlaintextWithoutCallerToken(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
server := grpc.NewServer()
|
||||
rpc := &tlsRPC{auth: make(chan []string, 1)}
|
||||
pb.RegisterNotificationsServiceServer(server, rpc)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
client, err := Open(Config{Address: listener.Addr().String()})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
|
||||
require.Empty(t, <-rpc.auth)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"strings"
|
||||
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ type RefreshTokenDB struct {
|
||||
collection Collections
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db source.Source) (*RefreshTokenDB, error) {
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) {
|
||||
return CreateWithCollections(logger, db, Collections{})
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ func (c Collections) WithDefaults() Collections {
|
||||
return c
|
||||
}
|
||||
|
||||
func CreateWithCollections(logger mlogger.Logger, db source.Source, collections Collections) (*RefreshTokenDB, error) {
|
||||
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) {
|
||||
collections = collections.WithDefaults()
|
||||
p := &RefreshTokenDB{
|
||||
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
|
||||
clients: repository.CreateRepository(db, collections.Clients),
|
||||
clients: repository.CreateMongoRepository(db, collections.Clients),
|
||||
collection: collections,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import (
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -56,7 +55,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.D
|
||||
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
|
||||
logger := factory.NewLogger(true)
|
||||
|
||||
db, err := refreshtokens.Create(logger, source.Fixed(database))
|
||||
db, err := refreshtokens.Create(logger, database)
|
||||
require.NoError(t, err, "failed to create refresh tokens db")
|
||||
|
||||
cleanup := func() {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) {
|
||||
if !validScope(req.Scope) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope")
|
||||
}
|
||||
return s.begin(ctx, req, nil)
|
||||
}
|
||||
func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) {
|
||||
if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required")
|
||||
}
|
||||
a, p, version, err := s.profile(ctx, req.Subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
factor, err := s.preferred(p)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if enrollment != nil {
|
||||
factor = *enrollment
|
||||
}
|
||||
// Repeating an operation key resolves to the same immutable challenge snapshot.
|
||||
id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24])
|
||||
expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond)
|
||||
if !req.Deadline.IsZero() && req.Deadline.Before(expires) {
|
||||
expires = req.Deadline.UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
return model.Challenge{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires}
|
||||
candidate.ID = id
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
r, err := s.store.Challenge(tx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
r = candidate
|
||||
if err = s.store.CreateChallenge(tx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version {
|
||||
return nil, merrors.DataConflict("second-factor request changed")
|
||||
}
|
||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
return s.deliver(ctx, result)
|
||||
}
|
||||
func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) {
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
r, err := s.record(tx, id, req.Subject, req.Scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
return s.deliver(ctx, result)
|
||||
}
|
||||
|
||||
type issued struct {
|
||||
record *model.Record
|
||||
delivery model.Delivery
|
||||
resendAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) {
|
||||
if r.VerifiedAt != nil {
|
||||
return issued{}, verification.ErorrTokenAlreadyUsed()
|
||||
}
|
||||
if !r.ExpiresAt.After(time.Now()) {
|
||||
return issued{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) {
|
||||
return issued{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = "unresolved"
|
||||
}
|
||||
secretKey := s.key("code", r.ID.Hex(), key)
|
||||
now := time.Now().UTC()
|
||||
req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID).
|
||||
WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey).
|
||||
WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now))
|
||||
token, err := s.tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return issued{}, err
|
||||
}
|
||||
if !token.ExpiresAt.After(now) {
|
||||
return issued{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil
|
||||
}
|
||||
func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) {
|
||||
issued, ok := result.(issued)
|
||||
if !ok {
|
||||
return model.Challenge{}, merrors.Internal("invalid second-factor issuance result")
|
||||
}
|
||||
driver := s.drivers[issued.record.Factor.Channel]
|
||||
if driver == nil {
|
||||
return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable")
|
||||
}
|
||||
if err := driver.Send(ctx, issued.delivery); err != nil {
|
||||
s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel())))
|
||||
return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable")
|
||||
}
|
||||
s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel())))
|
||||
return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil
|
||||
}
|
||||
|
||||
func preferenceScope(factorID string) model.Scope {
|
||||
return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
)
|
||||
|
||||
type Email struct{ Sender notifications.Sender }
|
||||
|
||||
func (Email) Channel() model.Channel { return model.Email }
|
||||
func (Email) Normalize(address string) (string, error) { return customer.NormalizeEmail(address) }
|
||||
func (Email) Mask(address string) string { return mask.Email(address) }
|
||||
func (d Email) Send(ctx context.Context, m model.Delivery) error {
|
||||
template := "twofactor.code"
|
||||
if m.Scope.Purpose == vmodel.PurposeLogin {
|
||||
template = "auth.login"
|
||||
}
|
||||
return d.Sender.Send(ctx, notifications.Message{Key: m.Key, UserID: m.Subject.Hex(), Email: m.Factor.Address, Locale: m.Locale, Template: template, Parameters: map[string]string{"code": m.Code}, ExpiresAt: m.ExpiresAt})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// Package factorhttp exposes authenticated factor settings. Operation scope and
|
||||
// account ownership are resolved on the server; raw proofs never cross HTTP.
|
||||
package factorhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Registrar interface {
|
||||
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||
}
|
||||
type router struct {
|
||||
service *twofactor.Service
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) {
|
||||
h := &router{service: service, logger: logger.Named("twofactor_http")}
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend)
|
||||
}
|
||||
func decode(r *http.Request, dst any) error {
|
||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func requestIP(r *http.Request) string {
|
||||
if ip := ipguard.ClientIP(r); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err == nil {
|
||||
response.Accepted(h.logger, data).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
var handler http.HandlerFunc
|
||||
if throttle, ok := verification.Throttle(err); ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds())))))
|
||||
handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code")
|
||||
} else {
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists")
|
||||
case errors.Is(err, merrors.ErrNotImplemented):
|
||||
handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable")
|
||||
default:
|
||||
h.logger.Warn("Second-factor settings dependency failed")
|
||||
handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry")
|
||||
}
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type challengeDTO struct {
|
||||
ID string `json:"challengeId"`
|
||||
Channel model.Channel `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func mapChallenge(c model.Challenge) challengeDTO {
|
||||
return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt}
|
||||
}
|
||||
|
||||
type choiceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Channel model.Channel `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
type settingsDTO struct {
|
||||
Required bool `json:"required"`
|
||||
Preferred string `json:"preferred"`
|
||||
Factors []choiceDTO `json:"factors"`
|
||||
}
|
||||
|
||||
func mapSettings(s model.Settings) settingsDTO {
|
||||
result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}}
|
||||
for _, f := range s.Factors {
|
||||
result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target})
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
s, err := h.service.Settings(r.Context(), a.ID)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
Channel model.Channel `json:"channel"`
|
||||
Address string `json:"address"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"challengeId"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"factorId"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
Factor string `json:"factorId"`
|
||||
ID string `json:"challengeId"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"challengeId"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package factorhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type accountRoutes map[string]sresponse.AccountHandlerFunc
|
||||
|
||||
func (r accountRoutes) AccountHandler(_ mservice.Type, path string, _ api.HTTPMethod, h sresponse.AccountHandlerFunc) {
|
||||
r[path] = h
|
||||
}
|
||||
|
||||
func TestSettingsTransportRejectsCallerOwnedScopeAndDestinationOverrides(t *testing.T) {
|
||||
routes := accountRoutes{}
|
||||
Install(routes, nil, zap.NewNop())
|
||||
// Every route is registered through the authenticated-account contract.
|
||||
require.Len(t, routes, 6)
|
||||
for _, tc := range []struct{ path, body string }{
|
||||
{"/two-factor/enroll", `{"channel":"email","address":"a@example.com","subject":"other-account"}`},
|
||||
{"/two-factor/enroll/verify", `{"challengeId":"id","code":"123456","purpose":"login"}`},
|
||||
{"/two-factor/preference", `{"factorId":"id","address":"arbitrary@example.com"}`},
|
||||
{"/two-factor/preference/verify", `{"factorId":"id","code":"123456","verified":true}`},
|
||||
{"/two-factor/resend", `{"challengeId":"id","target":"arbitrary@example.com"}`},
|
||||
{"/two-factor/enroll", `{"channel":"email"} {"channel":"sms"}`},
|
||||
} {
|
||||
t.Run(tc.path+tc.body, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
routes[tc.path](req, &customer.Account{}, nil).ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
require.NotContains(t, w.Body.String(), "arbitrary@example.com")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
storable "git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
const Email Channel = "email"
|
||||
|
||||
// Scope is supplied by the owning operation, never accepted as an authorization
|
||||
// decision from a caller's HTTP body. ContextRef identifies the exact operation.
|
||||
type Scope struct {
|
||||
Purpose verification.VerificationPurpose `bson:"purpose"`
|
||||
ContextRef string `bson:"contextRef"`
|
||||
}
|
||||
type Request struct {
|
||||
Subject bson.ObjectID
|
||||
Scope Scope
|
||||
Deadline time.Time
|
||||
IdempotencyKey string
|
||||
IP string
|
||||
}
|
||||
type Factor struct {
|
||||
ID string `bson:"id"`
|
||||
Channel Channel `bson:"channel"`
|
||||
Address string `bson:"address"`
|
||||
Verified bool `bson:"verified"`
|
||||
}
|
||||
type Profile struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Preferred string `bson:"preferred"`
|
||||
Factors []Factor `bson:"factors"`
|
||||
Revision string `bson:"revision"`
|
||||
Fence string `bson:"fence"`
|
||||
}
|
||||
|
||||
func (*Profile) Collection() string { return "two_factor_profiles" }
|
||||
|
||||
type Record struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Subject bson.ObjectID `bson:"subject"`
|
||||
Scope Scope `bson:"scope"`
|
||||
Factor Factor `bson:"factor"`
|
||||
ProfileVersion string `bson:"profileVersion"`
|
||||
Locale string `bson:"locale"`
|
||||
ExpiresAt time.Time `bson:"expiresAt"`
|
||||
VerifiedAt *time.Time `bson:"verifiedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (*Record) Collection() string { return "two_factor_challenges" }
|
||||
|
||||
type Challenge struct {
|
||||
ID string
|
||||
Channel Channel
|
||||
Target string
|
||||
ExpiresAt, ResendAt time.Time
|
||||
}
|
||||
type Proof struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Delivery struct {
|
||||
Key string
|
||||
Subject bson.ObjectID
|
||||
Scope Scope
|
||||
Factor Factor
|
||||
Locale, Code string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Choice struct {
|
||||
ID string
|
||||
Channel Channel
|
||||
Target string
|
||||
}
|
||||
type Settings struct {
|
||||
Required bool
|
||||
Preferred string
|
||||
Factors []Choice
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"time"
|
||||
)
|
||||
|
||||
func isRejection(err error) bool {
|
||||
return errors.Is(err, verification.ErrTokenNotFound) || errors.Is(err, verification.ErrTokenAlreadyUsed) || errors.Is(err, verification.ErrTokenExpired) || errors.Is(err, verification.ErrTokenAttemptsExceeded)
|
||||
}
|
||||
func (s *Service) saveProfile(ctx context.Context, p *model.Profile) error {
|
||||
_, err := s.store.Profile(ctx, p.ID)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return s.store.CreateProfile(ctx, p)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.UpdateProfile(ctx, p)
|
||||
}
|
||||
func (s *Service) Settings(ctx context.Context, subject bson.ObjectID) (model.Settings, error) {
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
result := model.Settings{Required: true, Preferred: p.Preferred}
|
||||
for _, f := range p.Factors {
|
||||
if f.Verified {
|
||||
if driver := s.drivers[f.Channel]; driver != nil {
|
||||
result.Factors = append(result.Factors, model.Choice{ID: f.ID, Channel: f.Channel, Target: driver.Mask(f.Address)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Enroll(ctx context.Context, subject bson.ObjectID, channel model.Channel, address, key, ip string) (model.Challenge, error) {
|
||||
driver := s.drivers[channel]
|
||||
if driver == nil {
|
||||
return model.Challenge{}, merrors.NotImplemented("second-factor channel is not supported")
|
||||
}
|
||||
normalized, err := driver.Normalize(address)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if len(p.Factors) >= 5 {
|
||||
return model.Challenge{}, merrors.InvalidArgument("maximum enrolled factors reached")
|
||||
}
|
||||
for _, f := range p.Factors {
|
||||
if f.Channel == channel && f.Address == normalized {
|
||||
return model.Challenge{}, merrors.DataConflict("factor already enrolled")
|
||||
}
|
||||
}
|
||||
f := model.Factor{ID: s.key("factor", subject.Hex(), string(channel), normalized)[:24], Channel: channel, Address: normalized}
|
||||
scope := model.Scope{Purpose: vmodel.PurposeFactorEnrollment, ContextRef: "enroll:" + f.ID}
|
||||
return s.begin(ctx, model.Request{Subject: subject, Scope: scope, IdempotencyKey: key, IP: ip}, &f)
|
||||
}
|
||||
func (s *Service) ConfirmEnrollment(ctx context.Context, subject bson.ObjectID, id, code string) (model.Settings, error) {
|
||||
cid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return model.Settings{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, cid)
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
if r.Subject != subject || r.Scope.Purpose != vmodel.PurposeFactorEnrollment {
|
||||
return model.Settings{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
err = s.complete(ctx, subject, r.Scope, id, code, func(tx context.Context) error {
|
||||
_, p, _, err := s.profile(tx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Factors) >= 5 {
|
||||
return merrors.InvalidArgument("maximum enrolled factors reached")
|
||||
}
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == r.Factor.ID {
|
||||
return merrors.DataConflict("factor already enrolled")
|
||||
}
|
||||
}
|
||||
factor := r.Factor
|
||||
factor.Verified = true
|
||||
p.Factors = append(p.Factors, factor)
|
||||
p.Revision = rand.Text()
|
||||
return s.saveProfile(tx, p)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
return s.Settings(ctx, subject)
|
||||
}
|
||||
func (s *Service) BeginPreference(ctx context.Context, subject bson.ObjectID, factorID, key, ip string) (model.Challenge, error) {
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if !s.available(p, factorID) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("verified supported factor required")
|
||||
}
|
||||
return s.Begin(ctx, model.Request{Subject: subject, Scope: preferenceScope(factorID), IdempotencyKey: key, IP: ip, Deadline: time.Now().Add(TTL)})
|
||||
}
|
||||
func (s *Service) ConfirmPreference(ctx context.Context, subject bson.ObjectID, factorID, id, code string) (model.Settings, error) {
|
||||
scope := preferenceScope(factorID)
|
||||
err := s.Complete(ctx, subject, scope, id, code, func(tx context.Context) error {
|
||||
_, p, _, err := s.profile(tx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.available(p, factorID) {
|
||||
return merrors.InvalidArgument("verified supported factor required")
|
||||
}
|
||||
p.Preferred = factorID
|
||||
p.Revision = rand.Text()
|
||||
return s.saveProfile(tx, p)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
return s.Settings(ctx, subject)
|
||||
}
|
||||
func (s *Service) available(p *model.Profile, id string) bool {
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == id && f.Verified && s.drivers[f.Channel] != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResendSettings cannot be used to send or authorize a pending login challenge.
|
||||
func (s *Service) ResendSettings(ctx context.Context, subject bson.ObjectID, id, key, ip string) (model.Challenge, error) {
|
||||
cid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return model.Challenge{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, cid)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if r.Subject != subject || (r.Scope.Purpose != vmodel.PurposeFactorEnrollment && r.Scope.Purpose != vmodel.PurposeFactorPreference) {
|
||||
return model.Challenge{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
return s.Resend(ctx, model.Request{Subject: subject, Scope: r.Scope, IdempotencyKey: key, IP: ip}, id)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type verificationOutcome struct {
|
||||
proof model.Proof
|
||||
rejected error
|
||||
}
|
||||
|
||||
func (s *Service) Verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
|
||||
if !validScope(scope) {
|
||||
return model.Proof{}, merrors.InvalidArgument("unsupported second-factor operation scope")
|
||||
}
|
||||
return s.verify(ctx, subject, scope, id, code)
|
||||
}
|
||||
func (s *Service) verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
||||
return model.Proof{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
r, err := s.record(tx, id, subject, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.VerifiedAt != nil {
|
||||
return nil, verification.ErorrTokenAlreadyUsed()
|
||||
}
|
||||
// Commit rejected verification attempts while rolling back infrastructure
|
||||
// failures. Sendico's Consume records misses in the enclosing transaction.
|
||||
_, err = s.tokens.Consume(tx, subject, scope.Purpose, r.ID.Hex(), code)
|
||||
if err != nil {
|
||||
if isRejection(err) {
|
||||
return verificationOutcome{rejected: err}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ttl := min(proofTTL, time.Until(r.ExpiresAt))
|
||||
if ttl <= 0 {
|
||||
return nil, verification.ErorrTokenExpired()
|
||||
}
|
||||
token, err := s.tokens.CreateWithResult(tx, verification.NewLinkRequest(subject, vmodel.PurposeFactorProof, r.ID.Hex()).WithContextRef(scopeKey(scope)).WithTTL(ttl).WithIdempotencyKey(s.key("proof", r.ID.Hex())))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
r.VerifiedAt = &now
|
||||
if err = s.store.UpdateChallenge(tx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verificationOutcome{proof: model.Proof{Token: token.VerificationToken, ExpiresAt: token.ExpiresAt}}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return model.Proof{}, err
|
||||
}
|
||||
outcome, ok := result.(verificationOutcome)
|
||||
if !ok {
|
||||
return model.Proof{}, merrors.Internal("invalid second-factor verification result")
|
||||
}
|
||||
if outcome.rejected != nil {
|
||||
return model.Proof{}, outcome.rejected
|
||||
}
|
||||
s.logger.Debug("Second-factor proof prepared", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
|
||||
return outcome.proof, nil
|
||||
}
|
||||
func (s *Service) Consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
|
||||
if !validScope(scope) {
|
||||
return merrors.InvalidArgument("unsupported second-factor operation scope")
|
||||
}
|
||||
return s.consume(ctx, subject, scope, proof, action)
|
||||
}
|
||||
func (s *Service) consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
|
||||
if action == nil || len(proof.Token) != 43 {
|
||||
return merrors.InvalidArgument("proof and transactional action required")
|
||||
}
|
||||
_, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
token, err := s.tokens.Consume(tx, subject, vmodel.PurposeFactorProof, scopeKey(scope), proof.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := s.record(tx, token.Target, subject, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.VerifiedAt == nil {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
// Touch the profile in this transaction so a concurrent preference update
|
||||
// conflicts and cannot let an old-factor proof authorize a later action.
|
||||
_, p, _, err := s.profile(tx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Fence = rand.Text()
|
||||
if err = s.saveProfile(tx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, action(tx)
|
||||
})
|
||||
if err == nil {
|
||||
s.logger.Debug("Second-factor proof consumption prepared", zap.String("purpose", string(scope.Purpose)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Complete atomically verifies the code, consumes its proof and commits the
|
||||
// owning operation. A failed operation leaves the code retryable; a rejected
|
||||
// code still commits its attempt counter.
|
||||
func (s *Service) Complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
|
||||
if !validScope(scope) {
|
||||
return merrors.InvalidArgument("unsupported second-factor operation scope")
|
||||
}
|
||||
return s.complete(ctx, subject, scope, id, code, action)
|
||||
}
|
||||
func (s *Service) complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
proof, err := s.verify(tx, subject, scope, id, code)
|
||||
if err != nil {
|
||||
if isRejection(err) {
|
||||
return verificationOutcome{rejected: err}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err = s.consume(tx, subject, scope, proof, action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verificationOutcome{}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outcome, ok := result.(verificationOutcome)
|
||||
if !ok {
|
||||
return merrors.Internal("invalid second-factor completion result")
|
||||
}
|
||||
if outcome.rejected == nil {
|
||||
s.logger.Info("Second-factor operation completed", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
|
||||
} else {
|
||||
s.logger.Info("Second-factor code rejected", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
|
||||
}
|
||||
return outcome.rejected
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Package twofactor owns second-factor policy, challenges and one-use proofs.
|
||||
// It deliberately has no dependency on auth HTTP, JWTs or session creation.
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const TTL = 10 * time.Minute
|
||||
const Cooldown = time.Minute
|
||||
const proofTTL = 2 * time.Minute
|
||||
const primary = "primary-email"
|
||||
|
||||
// API is consumed by auth and other operation owners. Complete joins code
|
||||
// verification, proof consumption and the owner's local database action.
|
||||
type API interface {
|
||||
Begin(context.Context, model.Request) (model.Challenge, error)
|
||||
Resend(context.Context, model.Request, string) (model.Challenge, error)
|
||||
Complete(context.Context, bson.ObjectID, model.Scope, string, string, func(context.Context) error) error
|
||||
}
|
||||
type Store interface {
|
||||
Profile(context.Context, bson.ObjectID) (*model.Profile, error)
|
||||
CreateProfile(context.Context, *model.Profile) error
|
||||
UpdateProfile(context.Context, *model.Profile) error
|
||||
Challenge(context.Context, bson.ObjectID) (*model.Record, error)
|
||||
CreateChallenge(context.Context, *model.Record) error
|
||||
UpdateChallenge(context.Context, *model.Record) error
|
||||
}
|
||||
type Driver interface {
|
||||
Channel() model.Channel
|
||||
Normalize(string) (string, error)
|
||||
Mask(string) string
|
||||
Send(context.Context, model.Delivery) error
|
||||
}
|
||||
type Service struct {
|
||||
accounts account.DB
|
||||
tokens verification.DB
|
||||
store Store
|
||||
tx transaction.Factory
|
||||
secret []byte
|
||||
drivers map[model.Channel]Driver
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(accounts account.DB, tokens verification.DB, store Store, tx transaction.Factory, secret []byte, logger mlogger.Logger, drivers ...Driver) (*Service, error) {
|
||||
if accounts == nil || tokens == nil || store == nil || tx == nil || len(secret) < 32 || logger == nil {
|
||||
return nil, merrors.InvalidArgument("2FA dependencies and private key material are required")
|
||||
}
|
||||
s := &Service{accounts: accounts, tokens: tokens, store: store, tx: tx, secret: append([]byte(nil), secret...), drivers: map[model.Channel]Driver{}, logger: logger.Named("twofactor")}
|
||||
for _, d := range drivers {
|
||||
if d == nil || s.drivers[d.Channel()] != nil {
|
||||
return nil, merrors.InvalidArgument("unique second-factor channel drivers required")
|
||||
}
|
||||
s.drivers[d.Channel()] = d
|
||||
}
|
||||
if s.drivers[model.Email] == nil {
|
||||
return nil, merrors.InvalidArgument("verified account email channel is required")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Service) key(parts ...string) string {
|
||||
h := hmac.New(sha256.New, s.secret)
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
func validScope(scope model.Scope) bool {
|
||||
if strings.TrimSpace(scope.ContextRef) == "" || len(scope.ContextRef) > 256 || strings.ContainsRune(scope.ContextRef, 0) {
|
||||
return false
|
||||
}
|
||||
switch scope.Purpose {
|
||||
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func scopeKey(scope model.Scope) string { return string(scope.Purpose) + ":" + scope.ContextRef }
|
||||
func (s *Service) profile(ctx context.Context, id bson.ObjectID) (*customer.Account, *model.Profile, string, error) {
|
||||
a := &customer.Account{}
|
||||
if err := s.accounts.Get(ctx, id, a); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, "", merrors.Unauthorized("active verified account required")
|
||||
}
|
||||
p, err := s.store.Profile(ctx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p = &model.Profile{Preferred: primary, Revision: "initial"}
|
||||
p.ID = id
|
||||
} else if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
// The primary address is authorized by account activation, never by request data.
|
||||
factors := []model.Factor{{ID: primary, Channel: model.Email, Address: a.Login, Verified: true}}
|
||||
for _, f := range p.Factors {
|
||||
if f.ID != primary {
|
||||
factors = append(factors, f)
|
||||
}
|
||||
}
|
||||
p.Factors = factors
|
||||
version := s.key("profile", id.Hex(), a.Login, p.Revision)
|
||||
return a, p, version, nil
|
||||
}
|
||||
func (s *Service) preferred(p *model.Profile) (model.Factor, error) {
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == p.Preferred && f.Verified && s.drivers[f.Channel] != nil {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
return model.Factor{}, merrors.Unauthorized("configured second factor is unavailable")
|
||||
}
|
||||
func (s *Service) record(ctx context.Context, idText string, subject bson.ObjectID, scope model.Scope) (*model.Record, error) {
|
||||
id, err := bson.ObjectIDFromHex(idText)
|
||||
if err != nil {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Subject != subject || r.Scope != scope {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
if !r.ExpiresAt.After(time.Now()) {
|
||||
return nil, verification.ErorrTokenExpired()
|
||||
}
|
||||
_, _, version, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != r.ProfileVersion {
|
||||
return nil, merrors.Unauthorized("second-factor settings changed; restart verification")
|
||||
}
|
||||
if s.drivers[r.Factor.Channel] == nil {
|
||||
return nil, merrors.NotImplemented("second-factor channel unavailable")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package twofactor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedDriver struct {
|
||||
deliveries []model.Delivery
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (*capturedDriver) Channel() model.Channel { return model.Email }
|
||||
func (*capturedDriver) Normalize(s string) (string, error) { return customer.NormalizeEmail(s) }
|
||||
func (*capturedDriver) Mask(string) string { return "masked" }
|
||||
func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error {
|
||||
d.deliveries = append(d.deliveries, m)
|
||||
if d.fail {
|
||||
return merrors.ServiceUnavailable("queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestServiceTransactionsAndFactors(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "twofactor"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
require.NoError(t, err)
|
||||
driver := &capturedDriver{}
|
||||
service, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), []byte(strings.Repeat("s", 32)), zap.NewNop(), driver)
|
||||
require.NoError(t, err)
|
||||
account := func() *customer.Account {
|
||||
a := &customer.Account{Status: customer.AccountActive}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = a.ID.Hex() + "@example.com"
|
||||
a.Locale = "ru"
|
||||
require.NoError(t, stores.Accounts.Create(ctx, a))
|
||||
return a
|
||||
}
|
||||
request := func(a *customer.Account) model.Request {
|
||||
return model.Request{Subject: a.ID, Scope: model.Scope{Purpose: vmodel.PurposeLogin, ContextRef: bson.NewObjectID().Hex()}, IdempotencyKey: "begin", IP: a.ID.Hex()}
|
||||
}
|
||||
begin := func(r model.Request) (model.Challenge, string) {
|
||||
c, err := service.Begin(ctx, r)
|
||||
require.NoError(t, err)
|
||||
return c, driver.deliveries[len(driver.deliveries)-1].Code
|
||||
}
|
||||
action := func(context.Context) error { return nil }
|
||||
t.Run("delivery retry and immutable deadline", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
r.Deadline = time.Now().Add(3 * time.Minute)
|
||||
driver.fail = true
|
||||
_, err := service.Begin(ctx, r)
|
||||
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
|
||||
first := driver.deliveries[len(driver.deliveries)-1]
|
||||
driver.fail = false
|
||||
c, code := begin(r)
|
||||
require.Equal(t, first.Code, code)
|
||||
require.Equal(t, first.Key, driver.deliveries[len(driver.deliveries)-1].Key)
|
||||
require.WithinDuration(t, first.ExpiresAt, c.ExpiresAt, time.Millisecond)
|
||||
require.False(t, c.ExpiresAt.After(r.Deadline))
|
||||
require.Equal(t, a.Login, first.Factor.Address)
|
||||
require.Equal(t, "ru", first.Locale)
|
||||
require.Equal(t, "masked", c.Target)
|
||||
r.IdempotencyKey = "resend"
|
||||
_, err = service.Resend(ctx, r, c.ID)
|
||||
_, throttled := verification.Throttle(err)
|
||||
require.True(t, throttled)
|
||||
_, err = service.ResendSettings(ctx, a.ID, c.ID, "settings", r.IP)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
})
|
||||
t.Run("scope and one use proof", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
_, err := service.Verify(ctx, account().ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
other := r.Scope
|
||||
other.ContextRef = "another-operation"
|
||||
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
other = r.Scope
|
||||
other.Purpose = vmodel.PurposePayout
|
||||
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
proof, err := service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proof.Token, 43)
|
||||
require.Error(t, service.Consume(ctx, a.ID, other, proof, action))
|
||||
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, func(context.Context) error { return merrors.ServiceUnavailable("operation failed") }), merrors.ErrServiceUnavailable)
|
||||
require.NoError(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
|
||||
require.Error(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
|
||||
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenAlreadyUsed)
|
||||
})
|
||||
t.Run("atomic operation rollback and concurrent completion", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
err := service.Complete(ctx, a.ID, r.Scope, c.ID, code, func(tx context.Context) error {
|
||||
updated := &customer.Account{}
|
||||
if err := stores.Accounts.Get(tx, a.ID, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
updated.Name = "should rollback"
|
||||
if err := stores.Accounts.Update(tx, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return merrors.ServiceUnavailable("session write failed")
|
||||
})
|
||||
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
|
||||
saved := &customer.Account{}
|
||||
require.NoError(t, stores.Accounts.Get(ctx, a.ID, saved))
|
||||
require.Empty(t, saved.Name)
|
||||
id, err := bson.ObjectIDFromHex(c.ID)
|
||||
require.NoError(t, err)
|
||||
record, err := records.Challenge(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, record.VerifiedAt)
|
||||
var successes atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if service.Complete(ctx, a.ID, r.Scope, c.ID, code, action) == nil {
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.EqualValues(t, 1, successes.Load())
|
||||
})
|
||||
t.Run("bad attempts are committed even with an outer operation", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
for range 5 {
|
||||
require.Error(t, service.Complete(ctx, a.ID, r.Scope, c.ID, wrong, action))
|
||||
}
|
||||
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), verification.ErrTokenAttemptsExceeded)
|
||||
})
|
||||
t.Run("expired and inactive challenges fail closed", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
id, err := bson.ObjectIDFromHex(c.ID)
|
||||
require.NoError(t, err)
|
||||
record, err := records.Challenge(ctx, id)
|
||||
require.NoError(t, err)
|
||||
record.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, records.UpdateChallenge(ctx, record))
|
||||
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
a = account()
|
||||
r = request(a)
|
||||
c, code = begin(r)
|
||||
a.Status = customer.AccountBlocked
|
||||
require.NoError(t, stores.Accounts.Update(ctx, a))
|
||||
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), merrors.ErrUnauthorized)
|
||||
})
|
||||
t.Run("verified preference controls delivery and invalidates old proofs", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
old, oldCode := begin(r)
|
||||
proof, err := service.Verify(ctx, a.ID, r.Scope, old.ID, oldCode)
|
||||
require.NoError(t, err)
|
||||
_, err = service.Enroll(ctx, a.ID, "sms", "+10000000000", "sms", r.IP)
|
||||
require.ErrorIs(t, err, merrors.ErrNotImplemented)
|
||||
enroll, err := service.Enroll(ctx, a.ID, model.Email, "SECOND@example.com", "enroll", r.IP)
|
||||
require.NoError(t, err)
|
||||
code := driver.deliveries[len(driver.deliveries)-1].Code
|
||||
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
|
||||
settings, err := service.Settings(ctx, a.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, settings.Factors, 1)
|
||||
settings, err = service.ConfirmEnrollment(ctx, a.ID, enroll.ID, code)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, settings.Factors, 2)
|
||||
require.Equal(t, "primary-email", settings.Preferred)
|
||||
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, action), merrors.ErrUnauthorized)
|
||||
selected := settings.Factors[1].ID
|
||||
change, err := service.BeginPreference(ctx, a.ID, selected, "preference", r.IP)
|
||||
require.NoError(t, err)
|
||||
d := driver.deliveries[len(driver.deliveries)-1]
|
||||
require.Equal(t, a.Login, d.Factor.Address)
|
||||
_, err = service.ConfirmPreference(ctx, a.ID, "primary-email", change.ID, d.Code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
settings, err = service.ConfirmPreference(ctx, a.ID, selected, change.ID, d.Code)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selected, settings.Preferred)
|
||||
next := request(a)
|
||||
_, _ = begin(next)
|
||||
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
|
||||
})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
profiles *template.DBImp[*model.Profile]
|
||||
challenges *template.DBImp[*model.Record]
|
||||
}
|
||||
|
||||
func Open(logger mlogger.Logger, factory db.Factory) (*Store, error) {
|
||||
d, err := deps.ResolveDependencies(factory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{profiles: template.Create[*model.Profile](logger, "two_factor_profiles", d.Database), challenges: template.Create[*model.Record](logger, "two_factor_challenges", d.Database)}
|
||||
retention := int32(86400)
|
||||
err = s.challenges.Repository.CreateIndex(&ri.Definition{Name: "two_factor_expiry", Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, TTL: &retention})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Store) Profile(ctx context.Context, id bson.ObjectID) (*model.Profile, error) {
|
||||
p := &model.Profile{}
|
||||
err := s.profiles.Get(ctx, id, p)
|
||||
return p, err
|
||||
}
|
||||
func (s *Store) CreateProfile(ctx context.Context, p *model.Profile) error {
|
||||
return s.profiles.Create(ctx, p)
|
||||
}
|
||||
func (s *Store) UpdateProfile(ctx context.Context, p *model.Profile) error {
|
||||
return s.profiles.Update(ctx, p)
|
||||
}
|
||||
func (s *Store) Challenge(ctx context.Context, id bson.ObjectID) (*model.Record, error) {
|
||||
r := &model.Record{}
|
||||
err := s.challenges.Get(ctx, id, r)
|
||||
return r, err
|
||||
}
|
||||
func (s *Store) CreateChallenge(ctx context.Context, r *model.Record) error {
|
||||
return s.challenges.Create(ctx, r)
|
||||
}
|
||||
func (s *Store) UpdateChallenge(ctx context.Context, r *model.Record) error {
|
||||
return s.challenges.Update(ctx, r)
|
||||
}
|
||||
@@ -17,9 +17,6 @@ const (
|
||||
PurposePasswordReset VerificationPurpose = "password_reset"
|
||||
PurposeMagicLogin VerificationPurpose = "magic_login"
|
||||
PurposeLogin VerificationPurpose = "login"
|
||||
PurposeFactorEnrollment VerificationPurpose = "factor_enrollment"
|
||||
PurposeFactorPreference VerificationPurpose = "factor_preference"
|
||||
PurposeFactorProof VerificationPurpose = "factor_proof"
|
||||
PurposePayout VerificationPurpose = "payout"
|
||||
)
|
||||
|
||||
@@ -39,12 +36,6 @@ func VPFromString(s string) (VerificationPurpose, error) {
|
||||
return PurposeMagicLogin, nil
|
||||
case string(PurposeLogin):
|
||||
return PurposeLogin, nil
|
||||
case string(PurposeFactorEnrollment):
|
||||
return PurposeFactorEnrollment, nil
|
||||
case string(PurposeFactorPreference):
|
||||
return PurposeFactorPreference, nil
|
||||
case string(PurposeFactorProof):
|
||||
return PurposeFactorProof, nil
|
||||
case string(PurposePayout):
|
||||
return PurposePayout, nil
|
||||
default:
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
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
|
||||
@@ -27,6 +38,7 @@ 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),
|
||||
@@ -34,12 +46,18 @@ 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"
|
||||
|
||||
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,6 +132,8 @@ 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,6 +72,7 @@ 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),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("target", 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),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("target", 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,10 +140,7 @@ 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, err = syntheticIdempotencyKey()
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
idempotencyKey = syntheticIdempotencyKey()
|
||||
hasIdempotency = true
|
||||
}
|
||||
|
||||
@@ -249,12 +246,3 @@ 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))
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ const verificationContextIdempotencyIndexName = "uniq_verification_context_idemp
|
||||
|
||||
func Create(
|
||||
logger mlogger.Logger,
|
||||
db source.Source,
|
||||
db *mongo.Database,
|
||||
tf transaction.Factory,
|
||||
) (*verificationDB, error) {
|
||||
return CreateWithCollection(logger, db, tf, "")
|
||||
@@ -37,7 +37,7 @@ func Create(
|
||||
|
||||
func CreateWithCollection(
|
||||
logger mlogger.Logger,
|
||||
db source.Source,
|
||||
db *mongo.Database,
|
||||
tf transaction.Factory,
|
||||
collection string,
|
||||
) (*verificationDB, error) {
|
||||
|
||||
@@ -2,16 +2,15 @@ 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) {
|
||||
@@ -25,12 +24,8 @@ func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
return key, true
|
||||
}
|
||||
|
||||
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 syntheticIdempotencyKey() string {
|
||||
return "auto:" + bson.NewObjectID().Hex()
|
||||
}
|
||||
|
||||
func idempotencyContextFilter(
|
||||
@@ -40,7 +35,7 @@ func idempotencyContextFilter(
|
||||
return repository.Query().And(
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("target", request.Target),
|
||||
repository.Filter("idempotencyKey", idempotencyKey),
|
||||
)
|
||||
}
|
||||
@@ -80,12 +75,6 @@ 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,18 +62,6 @@ 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,13 +3,12 @@ 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) {
|
||||
@@ -175,16 +174,3 @@ 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)
|
||||
}
|
||||
@@ -4,38 +4,56 @@ import (
|
||||
"context"
|
||||
|
||||
mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
)
|
||||
|
||||
// Connection represents a low-level database connection lifecycle.
|
||||
type Connection interface {
|
||||
Disconnect(context.Context) error
|
||||
Ping(context.Context) error
|
||||
Disconnect(ctx context.Context) error
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// MongoConnection provides direct access to the underlying mongo client.
|
||||
type MongoConnection struct {
|
||||
database source.Source
|
||||
close func(context.Context) error
|
||||
client *mongo.Client
|
||||
database string
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Client() *mongo.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Database() *mongo.Database {
|
||||
return c.client.Database(c.database)
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Disconnect(ctx context.Context) error {
|
||||
return c.client.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Database() source.Source { return c.database }
|
||||
func (c *MongoConnection) Disconnect(ctx context.Context) error { return c.close(ctx) }
|
||||
func (c *MongoConnection) Ping(ctx context.Context) error {
|
||||
database, ctx, release, err := c.database.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
return database.Client().Ping(ctx, readpref.Primary())
|
||||
return c.client.Ping(ctx, readpref.Primary())
|
||||
}
|
||||
|
||||
// ConnectMongo returns a low-level MongoDB connection without constructing repositories.
|
||||
func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) {
|
||||
if config == nil || config.Driver != Mongo {
|
||||
return nil, merrors.InvalidArgument("MongoDB configuration is required")
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("database configuration is nil", "config")
|
||||
}
|
||||
database, close, err := mongoimpl.OpenSource(logger, config.Settings)
|
||||
if config.Driver != Mongo {
|
||||
return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver")
|
||||
}
|
||||
|
||||
client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MongoConnection{database: database, close: close}, nil
|
||||
|
||||
return &MongoConnection{
|
||||
client: client,
|
||||
database: settings.Database,
|
||||
}, nil
|
||||
}
|
||||
@@ -2,19 +2,19 @@ package mongo
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
|
||||
type Dependencies struct {
|
||||
Database source.Source
|
||||
Database *mongo.Database
|
||||
Transactions transaction.Factory
|
||||
}
|
||||
|
||||
type DatabaseProvider interface {
|
||||
MongoDatabase() source.Source
|
||||
MongoDatabase() *mongo.Database
|
||||
}
|
||||
|
||||
func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
|
||||
|
||||
@@ -3,18 +3,15 @@ package mongo
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/config"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/internal/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
dbvault "git.sendico.io/tech/sxchnge/shared/database/vault"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
@@ -23,7 +20,6 @@ import (
|
||||
|
||||
// Config represents configuration
|
||||
type Config struct {
|
||||
Vault *dbvault.Config `mapstructure:"vault"`
|
||||
Port *string `mapstructure:"port"`
|
||||
PortEnv *string `mapstructure:"port_env"`
|
||||
User *string `mapstructure:"user"`
|
||||
@@ -123,42 +119,37 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func ConnectClient(logger mlogger.Logger, settings sharedmodel.SettingsT) (*mongo.Client, *Config, *DBSettings, error) {
|
||||
config, dbSettings, err := decodeConfig(logger, settings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
client, err := dialMongo(logger, dbSettings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return client, config, dbSettings, nil
|
||||
}
|
||||
|
||||
// DB represents the structure of the database
|
||||
type DB struct {
|
||||
logger mlogger.Logger
|
||||
database source.Source
|
||||
close func(context.Context) error
|
||||
config *DBSettings
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (db *DB) db() *mongo.Database {
|
||||
return db.client.Database(db.config.Database)
|
||||
}
|
||||
|
||||
func (db *DB) MongoDatabase() *mongo.Database {
|
||||
return db.db()
|
||||
}
|
||||
|
||||
func (db *DB) MongoDatabase() source.Source { return db.database }
|
||||
func (db *DB) TransactionFactory() transaction.Factory {
|
||||
return transactionimp.CreateSourceFactory(db.database)
|
||||
}
|
||||
|
||||
// OpenSource owns either a fixed connection or a rotating Vault-backed source.
|
||||
func OpenSource(logger mlogger.Logger, settings sharedmodel.SettingsT) (source.Source, func(context.Context) error, error) {
|
||||
config, dbSettings, err := decodeConfig(logger, settings)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if config.Vault != nil {
|
||||
issuer, err := dbvault.New(*config.Vault, dbSettings.URI)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
manager, err := source.New(ctx, logger.Named("credentials"), dbSettings.Database, issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return manager, manager.Close, nil
|
||||
}
|
||||
client, err := dialMongo(logger, dbSettings)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return source.Fixed(client.Database(dbSettings.Database)), client.Disconnect, nil
|
||||
return transactionimp.CreateFactory(db.client)
|
||||
}
|
||||
|
||||
func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string {
|
||||
@@ -226,17 +217,24 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
|
||||
}
|
||||
|
||||
func (db *DB) CloseConnection() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := db.close(ctx); err != nil {
|
||||
db.logger.Warn("Failed to drain database connection", zap.Error(err))
|
||||
if err := db.client.Disconnect(context.Background()); err != nil {
|
||||
db.logger.Warn("Failed to close connection", zap.Error(err))
|
||||
}
|
||||
db.logger.Info("Database connection closed")
|
||||
}
|
||||
|
||||
// NewConnection creates a new database connection
|
||||
func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) {
|
||||
database, close, err := OpenSource(logger, settings)
|
||||
client, _, dbSettings, err := ConnectClient(logger, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DB{logger: logger, database: database, close: close}, nil
|
||||
|
||||
db := &DB{
|
||||
logger: logger.Named("db"),
|
||||
config: dbSettings,
|
||||
client: client,
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -12,14 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
|
||||
ctx := context.Background()
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if collection == nil {
|
||||
if r.collection == nil {
|
||||
return merrors.NoData("data collection is not set")
|
||||
}
|
||||
if len(def.Keys) == 0 {
|
||||
@@ -56,40 +49,28 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
|
||||
opts.SetSparse(def.Sparse)
|
||||
}
|
||||
|
||||
_, err = collection.Indexes().CreateOne(
|
||||
ctx,
|
||||
_, err := r.collection.Indexes().CreateOne(
|
||||
context.Background(),
|
||||
mongo.IndexModel{Keys: keys, Options: opts},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) DropIndex(ctx context.Context, name string) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if collection == nil {
|
||||
if r.collection == nil {
|
||||
return merrors.NoData("data collection is not set")
|
||||
}
|
||||
if name == "" {
|
||||
return merrors.InvalidArgument("index name is required", "index.name")
|
||||
}
|
||||
return collection.Indexes().DropOne(ctx, name)
|
||||
return r.collection.Indexes().DropOne(ctx, name)
|
||||
}
|
||||
|
||||
func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if collection == nil {
|
||||
if r.collection == nil {
|
||||
return nil, merrors.NoData("data collection is not set")
|
||||
}
|
||||
cursor, err := collection.Indexes().List(ctx)
|
||||
cursor, err := r.collection.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
|
||||
type MongoRepository struct {
|
||||
collectionName string
|
||||
source source.Source
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
func idFilter(id bson.ObjectID) bson.D {
|
||||
@@ -28,23 +27,10 @@ func idFilter(id bson.ObjectID) bson.D {
|
||||
}
|
||||
|
||||
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
|
||||
return NewRepository(source.Fixed(db), collection)
|
||||
return &MongoRepository{
|
||||
collectionName: collection,
|
||||
collection: db.Collection(collection),
|
||||
}
|
||||
|
||||
func NewRepository(database source.Source, collection string) *MongoRepository {
|
||||
return &MongoRepository{collectionName: collection, source: database}
|
||||
}
|
||||
|
||||
func (r *MongoRepository) acquire(ctx context.Context) (*mongo.Collection, context.Context, func(), error) {
|
||||
if r.source == nil {
|
||||
return nil, ctx, nil, merrors.FailedPrecondition("database source is not configured")
|
||||
}
|
||||
|
||||
database, scoped, release, err := r.source.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, ctx, nil, err
|
||||
}
|
||||
return database.Collection(r.collectionName), scoped, release, nil
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Collection() string {
|
||||
@@ -62,14 +48,8 @@ func prepareForWrite(obj storable.Storable) bson.ObjectID {
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
prepareForWrite(obj)
|
||||
_, err = collection.InsertOne(ctx, obj)
|
||||
_, err := r.collection.InsertOne(ctx, obj)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
if getFilter != nil {
|
||||
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
|
||||
@@ -82,12 +62,6 @@ func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, get
|
||||
}
|
||||
|
||||
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if len(objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -98,18 +72,12 @@ func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Sto
|
||||
docs[i] = obj
|
||||
}
|
||||
|
||||
_, err = collection.InsertMany(ctx, docs)
|
||||
_, err := r.collection.InsertMany(ctx, docs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = collection.FindOne(ctx, filter).Decode(result)
|
||||
err := r.collection.FindOne(ctx, filter).Decode(result)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return merrors.NoData(errMessage)
|
||||
}
|
||||
@@ -126,13 +94,7 @@ func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result stor
|
||||
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error)
|
||||
|
||||
func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
cursor, err := queryFunc(ctx, collection)
|
||||
cursor, err := queryFunc(ctx, r.collection)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return merrors.NoData("no_items_in_array")
|
||||
}
|
||||
@@ -149,7 +111,7 @@ func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc,
|
||||
}
|
||||
}
|
||||
|
||||
return cursor.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
|
||||
@@ -167,14 +129,8 @@ func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Qu
|
||||
}
|
||||
|
||||
func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if query == nil {
|
||||
cursor, err := collection.Find(ctx, bson.D{})
|
||||
cursor, err := r.collection.Find(ctx, bson.D{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -183,7 +139,7 @@ func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Que
|
||||
}()
|
||||
return cursor.All(ctx, result)
|
||||
}
|
||||
cursor, err := collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
|
||||
cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,30 +154,18 @@ func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Que
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
obj.Update()
|
||||
return collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
|
||||
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
|
||||
}
|
||||
|
||||
func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if query == nil {
|
||||
return false, merrors.InvalidArgument("filter is required", "filter")
|
||||
}
|
||||
if obj == nil {
|
||||
return false, merrors.InvalidArgument("object is required", "obj")
|
||||
}
|
||||
result, err := collection.ReplaceOne(ctx, query.BuildQuery(), obj)
|
||||
result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return false, merrors.DataConflict("duplicate_key")
|
||||
}
|
||||
@@ -232,39 +176,21 @@ func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
id := prepareForWrite(obj)
|
||||
_, err = collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
|
||||
_, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if id.IsZero() {
|
||||
return merrors.InvalidArgument("zero id provided while patching", "id")
|
||||
}
|
||||
_, err = collection.UpdateByID(ctx, id, patch.Build())
|
||||
_, err := r.collection.UpdateByID(ctx, id, patch.Build())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
result, err := collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
|
||||
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -272,16 +198,10 @@ func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, pa
|
||||
}
|
||||
|
||||
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
filter := query.BuildQuery()
|
||||
findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1})
|
||||
|
||||
cursor, err := collection.Find(ctx, filter, findOptions)
|
||||
cursor, err := r.collection.Find(ctx, filter, findOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -307,45 +227,24 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
_, err = collection.DeleteOne(ctx, idFilter(id))
|
||||
_, err := r.collection.DeleteOne(ctx, idFilter(id))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
_, err = collection.DeleteMany(ctx, query.BuildQuery())
|
||||
_, err := r.collection.DeleteMany(ctx, query.BuildQuery())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MongoRepository) Name() string {
|
||||
return r.collectionName
|
||||
return r.collection.Name()
|
||||
}
|
||||
|
||||
func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if cb == nil {
|
||||
return nil, merrors.InvalidArgument("transaction callback is required", "callback")
|
||||
}
|
||||
if transaction.ActiveMongoTransaction(ctx) {
|
||||
return cb(ctx)
|
||||
}
|
||||
session, err := collection.Database().Client().StartSession()
|
||||
session, err := r.collection.Database().Client().StartSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,14 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
opts := query.BuildOptions().SetLimit(1)
|
||||
cursor, err := collection.Find(ctx, query.BuildQuery(), opts)
|
||||
cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -36,22 +30,10 @@ func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Q
|
||||
}
|
||||
|
||||
func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return collection.CountDocuments(ctx, query.BuildQuery())
|
||||
return r.collection.CountDocuments(ctx, query.BuildQuery())
|
||||
}
|
||||
|
||||
func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) {
|
||||
collection, ctx, release, err := r.acquire(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if query == nil {
|
||||
return false, merrors.InvalidArgument("filter is required", "filter")
|
||||
}
|
||||
@@ -61,7 +43,7 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
|
||||
if result == nil {
|
||||
return false, merrors.InvalidArgument("result is required", "result")
|
||||
}
|
||||
err = collection.FindOneAndUpdate(
|
||||
err := r.collection.FindOneAndUpdate(
|
||||
ctx,
|
||||
query.BuildQuery(),
|
||||
patch.Build(),
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package transactionimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type MongoTransactionFactory struct{ database source.Source }
|
||||
type MongoTransactionFactory struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (f *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
|
||||
return &MongoTransaction{database: f.database}
|
||||
func (mtf *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
|
||||
return Create(mtf.client)
|
||||
}
|
||||
func CreateSourceFactory(database source.Source) transaction.Factory {
|
||||
return &MongoTransactionFactory{database: database}
|
||||
|
||||
func CreateFactory(client *mongo.Client) transaction.Factory {
|
||||
return &MongoTransactionFactory{client: client}
|
||||
}
|
||||
@@ -3,29 +3,32 @@ package transactionimp
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type MongoTransaction struct{ database source.Source }
|
||||
type MongoTransaction struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (t *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
if cb == nil {
|
||||
return nil, merrors.InvalidArgument("transaction callback is required")
|
||||
}
|
||||
database, ctx, release, err := t.database.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
// Reuse an outer customer workflow transaction when a token store joins it.
|
||||
if transaction.ActiveMongoTransaction(ctx) {
|
||||
return cb(ctx)
|
||||
}
|
||||
session, err := database.Client().StartSession()
|
||||
session, err := mt.client.StartSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.EndSession(ctx)
|
||||
return session.WithTransaction(ctx, cb)
|
||||
|
||||
callback := func(c context.Context) (any, error) {
|
||||
return cb(c)
|
||||
}
|
||||
|
||||
return session.WithTransaction(ctx, callback)
|
||||
}
|
||||
|
||||
func Create(client *mongo.Client) *MongoTransaction {
|
||||
return &MongoTransaction{client: client}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
@@ -54,11 +53,6 @@ type Repository interface {
|
||||
Collection() string
|
||||
}
|
||||
|
||||
func CreateRepository(db source.Source, collection string) Repository {
|
||||
return repositoryimp.NewRepository(db, collection)
|
||||
}
|
||||
|
||||
// CreateMongoRepository adapts an externally owned fixed database.
|
||||
func CreateMongoRepository(db *mongo.Database, collection string) Repository {
|
||||
return CreateRepository(source.Fixed(db), collection)
|
||||
return repositoryimp.NewMongoRepository(db, collection)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
dependencies "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||
storable "git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type rotationRecord struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Value string `bson:"value"`
|
||||
}
|
||||
|
||||
func (*rotationRecord) Collection() string { return "rotation" }
|
||||
|
||||
type leaseServer struct {
|
||||
admin *mongo.Database
|
||||
issued atomic.Int64
|
||||
revoked sync.Map
|
||||
outage atomic.Bool
|
||||
badPassword atomic.Bool
|
||||
}
|
||||
|
||||
func (s *leaseServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if s.outage.Load() {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/auth/approle/login":
|
||||
// A unique token owns each credential in this fake Vault HTTP boundary.
|
||||
token := fmt.Sprintf("test-token-%d", s.issued.Load()+1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": token, "lease_duration": 60}})
|
||||
case "/v1/database/creds/bff":
|
||||
n := s.issued.Add(1)
|
||||
user := fmt.Sprintf("lease%d", n)
|
||||
err := s.admin.RunCommand(r.Context(), bson.D{{Key: "createUser", Value: user}, {Key: "pwd", Value: "test-password"}, {Key: "roles", Value: bson.A{bson.M{"role": "readWrite", "db": "rotation"}}}}).Err()
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
password := "test-password"
|
||||
if s.badPassword.Load() {
|
||||
password = "wrong-password"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"lease_id": user, "lease_duration": 8, "data": map[string]string{"username": user, "password": password}})
|
||||
case "/v1/auth/token/revoke-self":
|
||||
user := "lease" + strings.TrimPrefix(r.Header.Get("X-Vault-Token"), "test-token-")
|
||||
_ = s.admin.RunCommand(r.Context(), bson.D{{Key: "dropUser", Value: user}}).Err()
|
||||
s.revoked.Store(user, true)
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotationPreservesRequestsCursorsAndTransactions(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"), mongodb.WithUsername("root"), mongodb.WithPassword("test-admin-password"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = container.Terminate(context.Background()) })
|
||||
raw, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
uri, err := url.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
q := uri.Query()
|
||||
q.Set("directConnection", "true")
|
||||
q.Set("authSource", "admin")
|
||||
uri.RawQuery = q.Encode()
|
||||
admin, err := mongo.Connect(options.Client().ApplyURI(uri.String()))
|
||||
require.NoError(t, err)
|
||||
defer admin.Disconnect(context.Background())
|
||||
fake := &leaseServer{admin: admin.Database("admin")}
|
||||
vault := httptest.NewServer(fake)
|
||||
defer vault.Close()
|
||||
directory := t.TempDir()
|
||||
role := filepath.Join(directory, "role")
|
||||
secret := filepath.Join(directory, "secret")
|
||||
require.NoError(t, os.WriteFile(role, []byte("test-role"), 0600))
|
||||
require.NoError(t, os.WriteFile(secret, []byte("test-secret"), 0600))
|
||||
uri.User = nil
|
||||
uri.Path = "/rotation"
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"database": "rotation", "uri": uri.String(), "vault": map[string]any{"address": vault.URL, "role": "bff", "role_id_file": role, "secret_id_file": secret}}})
|
||||
require.NoError(t, err)
|
||||
defer factory.CloseConnection()
|
||||
deps, err := dependencies.ResolveDependencies(factory)
|
||||
require.NoError(t, err)
|
||||
repo := repository.CreateRepository(deps.Database, "rotation")
|
||||
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "cursor-fixture"}, nil))
|
||||
|
||||
var failures, requests atomic.Int64
|
||||
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, e := repo.CountByFilter(r.Context(), repository.Query()); e != nil {
|
||||
failures.Add(1)
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
requests.Add(1)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer httpServer.Close()
|
||||
loadCtx, stopLoad := context.WithCancel(ctx)
|
||||
var load sync.WaitGroup
|
||||
defer func() { stopLoad(); load.Wait() }()
|
||||
for range 4 {
|
||||
load.Add(1)
|
||||
go func() {
|
||||
defer load.Done()
|
||||
for loadCtx.Err() == nil {
|
||||
res, e := http.Get(httpServer.URL)
|
||||
if e != nil {
|
||||
failures.Add(1)
|
||||
} else {
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
failures.Add(1)
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// A cursor and a transaction must keep generation 1 alive through publication.
|
||||
first, scope, release, err := deps.Database.Acquire(ctx)
|
||||
require.NoError(t, err)
|
||||
firstClient := first.Client()
|
||||
release()
|
||||
_, _, _, err = deps.Database.Acquire(scope)
|
||||
require.Error(t, err, "escaped operation scope must be rejected")
|
||||
txEntered := make(chan struct{})
|
||||
finishTx := make(chan struct{})
|
||||
txDone := make(chan error, 1)
|
||||
var entered sync.Once
|
||||
go func() {
|
||||
_, e := factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
if e := repo.Insert(tx, &rotationRecord{Value: "transaction-before"}, nil); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
entered.Do(func() { close(txEntered) })
|
||||
select {
|
||||
case <-finishTx:
|
||||
case <-tx.Done():
|
||||
return nil, tx.Err()
|
||||
}
|
||||
// Nested transaction must share the same session/generation.
|
||||
return repo.WithTransaction(tx, func(nested context.Context) (any, error) {
|
||||
return nil, repo.Insert(nested, &rotationRecord{Value: "transaction-after"}, nil)
|
||||
})
|
||||
})
|
||||
txDone <- e
|
||||
}()
|
||||
select {
|
||||
case <-txEntered:
|
||||
case err := <-txDone:
|
||||
t.Fatalf("transaction did not enter: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
cursorEntered := make(chan struct{})
|
||||
finishCursor := make(chan struct{})
|
||||
cursorDone := make(chan error, 1)
|
||||
go func() {
|
||||
var once sync.Once
|
||||
cursorDone <- repo.FindManyByFilter(ctx, repository.Query(), func(d *mongo.Cursor) error {
|
||||
once.Do(func() { close(cursorEntered) })
|
||||
select {
|
||||
case <-finishCursor:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case <-cursorEntered:
|
||||
case err := <-cursorDone:
|
||||
t.Fatalf("cursor did not enter: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
database, _, free, e := deps.Database.Acquire(ctx)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
defer free()
|
||||
return database.Client() != firstClient
|
||||
}, 6*time.Second, 20*time.Millisecond)
|
||||
_, revoked := fake.revoked.Load("lease1")
|
||||
require.False(t, revoked, "in-flight work lost its credentials")
|
||||
close(finishTx)
|
||||
require.NoError(t, <-txDone)
|
||||
_, revoked = fake.revoked.Load("lease1")
|
||||
require.False(t, revoked, "cursor must retain old pool")
|
||||
close(finishCursor)
|
||||
require.NoError(t, <-cursorDone)
|
||||
require.Eventually(t, func() bool { _, ok := fake.revoked.Load("lease1"); return ok }, 2*time.Second, 20*time.Millisecond)
|
||||
count, err := repo.CountByFilter(ctx, repository.Filter("value", "transaction-after"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, count)
|
||||
|
||||
// Reject a candidate with invalid credentials while retaining the valid pool.
|
||||
fake.badPassword.Store(true)
|
||||
before := fake.issued.Load()
|
||||
require.Eventually(t, func() bool { return fake.issued.Load() > before }, 5*time.Second, 20*time.Millisecond)
|
||||
require.Eventually(t, func() bool { _, ok := fake.revoked.Load(fmt.Sprintf("lease%d", before+1)); return ok }, time.Second, 10*time.Millisecond)
|
||||
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "during-bad-refresh"}, nil))
|
||||
fake.badPassword.Store(false)
|
||||
fake.outage.Store(true)
|
||||
require.NoError(t, repo.Insert(ctx, &rotationRecord{Value: "during-vault-outage"}, nil))
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fake.outage.Store(false)
|
||||
require.Eventually(t, func() bool { return fake.issued.Load() > before+1 }, 3*time.Second, 20*time.Millisecond)
|
||||
stopLoad()
|
||||
load.Wait()
|
||||
require.Greater(t, requests.Load(), int64(100))
|
||||
require.Zero(t, failures.Load(), "HTTP requests failed during rotation")
|
||||
|
||||
// If Vault stays unavailable beyond the lease, fail closed without restarting.
|
||||
fake.outage.Store(true)
|
||||
require.Eventually(t, func() bool { _, e := repo.CountByFilter(ctx, repository.Query()); return e != nil }, 10*time.Second, 50*time.Millisecond)
|
||||
fake.outage.Store(false)
|
||||
require.Eventually(t, func() bool { _, e := repo.CountByFilter(ctx, repository.Query()); return e == nil }, 6*time.Second, 20*time.Millisecond)
|
||||
// Transaction errors still roll back all writes after generation changes.
|
||||
_, err = repo.WithTransaction(ctx, func(tx context.Context) (any, error) {
|
||||
if e := repo.Insert(tx, &rotationRecord{Value: "rollback"}, nil); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, merrors.Internal("injected rollback")
|
||||
})
|
||||
require.Error(t, err)
|
||||
count, err = repo.CountByFilter(ctx, repository.Filter("value", "rollback"))
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
held, heldContext, releaseHeld, err := deps.Database.Acquire(ctx)
|
||||
require.NoError(t, err)
|
||||
manager := deps.Database.(interface{ Close(context.Context) error })
|
||||
short, stop := context.WithTimeout(ctx, 30*time.Millisecond)
|
||||
require.ErrorIs(t, manager.Close(short), context.DeadlineExceeded)
|
||||
stop()
|
||||
_, _, _, err = deps.Database.Acquire(ctx)
|
||||
require.Error(t, err)
|
||||
require.NoError(t, held.Client().Ping(heldContext, nil), "shutdown must drain, not kill held work")
|
||||
releaseHeld()
|
||||
closed, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer stop()
|
||||
require.NoError(t, manager.Close(closed))
|
||||
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Lease owns a credential and its revocation capability. Neither may be logged.
|
||||
type Lease struct {
|
||||
URI string
|
||||
ExpiresAt time.Time
|
||||
Revoke func(context.Context) error
|
||||
}
|
||||
type Issuer interface {
|
||||
Issue(context.Context) (Lease, error)
|
||||
}
|
||||
|
||||
type generation struct {
|
||||
client *mongo.Client
|
||||
lease Lease
|
||||
refs int
|
||||
retired bool
|
||||
drained chan struct{}
|
||||
}
|
||||
type pin struct {
|
||||
manager *Manager
|
||||
generation *generation
|
||||
active atomic.Bool
|
||||
}
|
||||
type pinKey struct{}
|
||||
|
||||
// Manager publishes a verified replacement before retiring the previous pool.
|
||||
// Every cursor, operation and transaction retains its originating generation.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
current *generation
|
||||
closed bool
|
||||
issuer Issuer
|
||||
database string
|
||||
logger mlogger.Logger
|
||||
cancel context.CancelFunc
|
||||
workerDone chan struct{}
|
||||
draining sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(ctx context.Context, logger mlogger.Logger, database string, issuer Issuer) (*Manager, error) {
|
||||
if database == "" || issuer == nil {
|
||||
return nil, merrors.InvalidArgument("database and credential issuer are required")
|
||||
}
|
||||
life, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{issuer: issuer, database: database, logger: logger, cancel: cancel, workerDone: make(chan struct{})}
|
||||
g, err := m.prepare(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
m.current = g
|
||||
go m.run(life)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, ctx, nil, err
|
||||
}
|
||||
if p, ok := ctx.Value(pinKey{}).(*pin); ok && p.manager == m {
|
||||
if !p.active.Load() {
|
||||
return nil, ctx, nil, merrors.FailedPrecondition("database operation scope has ended")
|
||||
}
|
||||
return p.generation.client.Database(m.database), ctx, func() {}, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
g := m.current
|
||||
if m.closed || g == nil || !time.Now().Before(g.lease.ExpiresAt) {
|
||||
m.mu.Unlock()
|
||||
return nil, ctx, nil, merrors.ServiceUnavailable("no valid database credential is available")
|
||||
}
|
||||
if session := mongo.SessionFromContext(ctx); session != nil && session.Client() != g.client {
|
||||
m.mu.Unlock()
|
||||
return nil, ctx, nil, merrors.FailedPrecondition("transaction has no matching database operation scope")
|
||||
}
|
||||
g.refs++
|
||||
m.mu.Unlock()
|
||||
p := &pin{manager: m, generation: g}
|
||||
p.active.Store(true)
|
||||
bounded, cancel := context.WithDeadline(ctx, g.lease.ExpiresAt)
|
||||
scoped := context.WithValue(bounded, pinKey{}, p)
|
||||
release := func() {
|
||||
if !p.active.CompareAndSwap(true, false) {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
g.refs--
|
||||
if g.retired && g.refs == 0 {
|
||||
close(g.drained)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return g.client.Database(m.database), scoped, release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) prepare(ctx context.Context) (*generation, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
lease, err := m.issuer.Issue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var client *mongo.Client
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
if client != nil {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = client.Disconnect(cleanup)
|
||||
cancel()
|
||||
}
|
||||
m.draining.Add(1)
|
||||
go func() { defer m.draining.Done(); m.revoke(lease) }()
|
||||
}
|
||||
}()
|
||||
if lease.Revoke == nil || time.Until(lease.ExpiresAt) < 2*time.Second {
|
||||
return nil, merrors.FailedPrecondition("database credential lifetime is too short")
|
||||
}
|
||||
client, err = mongo.Connect(options.Client().ApplyURI(lease.URI).SetServerSelectionTimeout(5 * time.Second))
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create replacement database pool")
|
||||
}
|
||||
if err = client.Ping(ctx, readpref.Primary()); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "validate replacement database pool")
|
||||
}
|
||||
if time.Until(lease.ExpiresAt) < time.Second {
|
||||
return nil, merrors.FailedPrecondition("replacement database credential expired during validation")
|
||||
}
|
||||
success = true
|
||||
return &generation{client: client, lease: lease, drained: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) retireLocked(g *generation) {
|
||||
g.retired = true
|
||||
if g.refs == 0 {
|
||||
close(g.drained)
|
||||
}
|
||||
m.draining.Add(1)
|
||||
go func() {
|
||||
defer m.draining.Done()
|
||||
<-g.drained
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := g.client.Disconnect(ctx); err != nil {
|
||||
m.logger.Warn("Retired database pool close failed")
|
||||
}
|
||||
m.revoke(g.lease)
|
||||
m.logger.Info("Retired database pool drained")
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) revoke(lease Lease) {
|
||||
if lease.Revoke == nil {
|
||||
return
|
||||
}
|
||||
// Expiration is the backstop when Vault is unavailable during bounded cleanup.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := lease.Revoke(ctx); err != nil {
|
||||
m.logger.Warn("Database credential cleanup failed; Vault expiry will revoke it")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (m *Manager) run(ctx context.Context) {
|
||||
defer close(m.workerDone)
|
||||
m.mu.Lock()
|
||||
wait := time.Until(m.current.lease.ExpiresAt) / 2
|
||||
m.mu.Unlock()
|
||||
backoff := 250 * time.Millisecond
|
||||
failureReported, expiryReported := false, false
|
||||
for {
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
next, err := m.prepare(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
remaining := time.Until(m.current.lease.ExpiresAt)
|
||||
m.mu.Unlock()
|
||||
if !failureReported {
|
||||
m.logger.Warn("Database credential refresh failed; retaining current pool", zap.Duration("valid_for", max(remaining, 0)))
|
||||
failureReported = true
|
||||
}
|
||||
if remaining <= 0 && !expiryReported {
|
||||
m.logger.Error("Database credentials expired; rejecting new operations until refresh succeeds")
|
||||
expiryReported = true
|
||||
}
|
||||
wait = backoff
|
||||
if remaining > 0 {
|
||||
wait = max(100*time.Millisecond, min(wait, remaining/4))
|
||||
}
|
||||
backoff = min(5*time.Second, backoff*2)
|
||||
|
||||
continue
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.closed {
|
||||
m.retireLocked(next)
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
old := m.current
|
||||
m.current = next
|
||||
m.retireLocked(old)
|
||||
m.mu.Unlock()
|
||||
failureReported, expiryReported = false, false
|
||||
backoff = 250 * time.Millisecond
|
||||
m.logger.Info("Database pool replaced without restarting service")
|
||||
wait = time.Until(next.lease.ExpiresAt) / 2
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
if !m.closed {
|
||||
m.closed = true
|
||||
m.cancel()
|
||||
m.retireLocked(m.current)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { <-m.workerDone; m.draining.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Package source owns Mongo connection generations and operation lifetimes.
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// Source pins one database/client until release. The returned context must be
|
||||
// passed to nested operations so transactions never cross client generations.
|
||||
type Source interface {
|
||||
Acquire(context.Context) (*mongo.Database, context.Context, func(), error)
|
||||
}
|
||||
|
||||
type fixed struct{ database *mongo.Database }
|
||||
|
||||
// Fixed adapts an externally owned database, e.g. a test fixture. It never closes it.
|
||||
func Fixed(database *mongo.Database) Source { return &fixed{database: database} }
|
||||
|
||||
func (s *fixed) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
|
||||
if s.database == nil {
|
||||
return nil, ctx, nil, merrors.FailedPrecondition("database is not configured")
|
||||
}
|
||||
if session := mongo.SessionFromContext(ctx); session != nil && session.Client() != s.database.Client() {
|
||||
return nil, ctx, nil, merrors.FailedPrecondition("transaction belongs to another database client")
|
||||
}
|
||||
return s.database, ctx, func() {}, nil
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type CascadeDeleterT = func(ctx context.Context, objectRef bson.ObjectID) error
|
||||
@@ -88,11 +88,11 @@ func (db *DBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID)
|
||||
func Create[T storable.Storable](
|
||||
logger mlogger.Logger,
|
||||
collection mservice.Type,
|
||||
db source.Source,
|
||||
db *mongo.Database,
|
||||
) *DBImp[T] {
|
||||
res := &DBImp[T]{
|
||||
Logger: logger.Named(collection),
|
||||
Repository: repository.CreateRepository(db, collection),
|
||||
Repository: repository.CreateMongoRepository(db, collection),
|
||||
}
|
||||
res.cdeleter = res.deleteCascadeStub
|
||||
return res
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// Package vault issues independently owned MongoDB leases through Vault AppRole.
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address"`
|
||||
DatabaseRole string `mapstructure:"role"`
|
||||
RoleIDFile string `mapstructure:"role_id_file"`
|
||||
SecretIDFile string `mapstructure:"secret_id_file"`
|
||||
}
|
||||
type Issuer struct {
|
||||
config Config
|
||||
uri *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(config Config, uri string) (*Issuer, error) {
|
||||
endpoint, err := url.Parse(config.Address)
|
||||
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("invalid Vault address")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(config.DatabaseRole) || config.RoleIDFile == "" || config.SecretIDFile == "" {
|
||||
return nil, merrors.InvalidArgument("Vault database role and AppRole files are required")
|
||||
}
|
||||
database, err := url.Parse(uri)
|
||||
if err != nil || database.Scheme != "mongodb" || database.Host == "" || database.User != nil {
|
||||
return nil, merrors.InvalidArgument("Vault requires a MongoDB URI without embedded credentials")
|
||||
}
|
||||
return &Issuer{config: config, uri: database, http: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Auth struct {
|
||||
Token string `json:"client_token"`
|
||||
TTL int `json:"lease_duration"`
|
||||
} `json:"auth"`
|
||||
TTL int `json:"lease_duration"`
|
||||
LeaseID string `json:"lease_id"`
|
||||
Data struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (i *Issuer) request(ctx context.Context, path, token string, body any) (response, error) {
|
||||
var out response
|
||||
var input io.Reader
|
||||
method := http.MethodGet
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return out, merrors.InternalWrap(err, "encode Vault request")
|
||||
}
|
||||
input = bytes.NewReader(raw)
|
||||
method = http.MethodPost
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(i.config.Address, "/")+"/v1/"+path, input)
|
||||
if err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "create Vault request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("X-Vault-Token", token)
|
||||
}
|
||||
res, err := i.http.Do(req)
|
||||
if err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Vault request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return out, merrors.ServiceUnavailable("Vault request was rejected")
|
||||
}
|
||||
if res.StatusCode == http.StatusNoContent {
|
||||
return out, nil
|
||||
}
|
||||
if err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return out, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid Vault response")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (i *Issuer) Issue(ctx context.Context) (source.Lease, error) {
|
||||
var lease source.Lease
|
||||
role, err := os.ReadFile(i.config.RoleIDFile)
|
||||
if err != nil {
|
||||
return lease, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Vault AppRole identity")
|
||||
}
|
||||
secret, err := os.ReadFile(i.config.SecretIDFile)
|
||||
if err != nil {
|
||||
return lease, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Vault AppRole credential")
|
||||
}
|
||||
started := time.Now()
|
||||
login, err := i.request(ctx, "auth/approle/login", "", map[string]string{"role_id": strings.TrimSpace(string(role)), "secret_id": strings.TrimSpace(string(secret))})
|
||||
if err != nil {
|
||||
return lease, err
|
||||
}
|
||||
if login.Auth.Token == "" {
|
||||
return lease, merrors.FailedPrecondition("Vault returned no application identity")
|
||||
}
|
||||
// One token owns one database lease. Revoking it never touches Agent's token
|
||||
// or the replacement generation, and requires only the default self policy.
|
||||
revoke := func(ctx context.Context) error {
|
||||
_, err := i.request(ctx, "auth/token/revoke-self", login.Auth.Token, map[string]any{})
|
||||
return err
|
||||
}
|
||||
if login.Auth.TTL <= 0 {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = revoke(cleanup)
|
||||
return lease, merrors.FailedPrecondition("Vault returned no bounded identity lifetime")
|
||||
}
|
||||
credential, err := i.request(ctx, "database/creds/"+i.config.DatabaseRole, login.Auth.Token, nil)
|
||||
if err != nil || credential.Data.Username == "" || credential.Data.Password == "" || credential.TTL <= 0 || credential.LeaseID == "" {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = revoke(cleanup)
|
||||
if err != nil {
|
||||
return lease, err
|
||||
}
|
||||
return lease, merrors.FailedPrecondition("Vault returned incomplete database credentials")
|
||||
}
|
||||
uri := *i.uri
|
||||
uri.User = url.UserPassword(credential.Data.Username, credential.Data.Password)
|
||||
return source.Lease{URI: uri.String(), ExpiresAt: started.Add(time.Duration(min(login.Auth.TTL, credential.TTL)) * time.Second), Revoke: revoke}, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssuerLifetimeEncodingAndCleanup(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
identityTTL, databaseTTL int
|
||||
reject bool
|
||||
wantError bool
|
||||
}{
|
||||
{"identity limits lease", 4, 20, false, false},
|
||||
{"database limits lease", 20, 4, false, false},
|
||||
{"invalid identity", 0, 20, false, true},
|
||||
{"invalid database lifetime", 20, 0, false, true},
|
||||
{"rejected issuance", 20, 20, true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var revoked atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/auth/approle/login":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": "private-token", "lease_duration": test.identityTTL}})
|
||||
case "/v1/database/creds/bff":
|
||||
if test.reject {
|
||||
http.Error(w, "private-password", 403)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"lease_id": "lease", "lease_duration": test.databaseTTL, "data": map[string]string{"username": "user@host", "password": "p@ss:/?#"}})
|
||||
case "/v1/auth/token/revoke-self":
|
||||
if r.Header.Get("X-Vault-Token") == "private-token" {
|
||||
revoked.Store(true)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
directory := t.TempDir()
|
||||
identity, secret := filepath.Join(directory, "identity"), filepath.Join(directory, "secret")
|
||||
require.NoError(t, os.WriteFile(identity, []byte("identity"), 0600))
|
||||
require.NoError(t, os.WriteFile(secret, []byte("secret"), 0600))
|
||||
issuer, err := New(Config{Address: server.URL, DatabaseRole: "bff", RoleIDFile: identity, SecretIDFile: secret}, "mongodb://localhost/db?authSource=admin")
|
||||
require.NoError(t, err)
|
||||
started := time.Now()
|
||||
lease, err := issuer.Issue(context.Background())
|
||||
if test.wantError {
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "private-")
|
||||
require.True(t, revoked.Load())
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, started.Add(4*time.Second), lease.ExpiresAt, time.Second)
|
||||
parsed, err := url.Parse(lease.URI)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user@host", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
require.Equal(t, "p@ss:/?#", password)
|
||||
require.False(t, revoked.Load())
|
||||
require.NoError(t, lease.Revoke(context.Background()))
|
||||
require.True(t, revoked.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ type DB struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, database source.Source, collection string) (*DB, error) {
|
||||
func Create(logger mlogger.Logger, database *mongo.Database, collection string) (*DB, error) {
|
||||
if database == nil {
|
||||
return nil, merrors.InvalidArgument("database is required", "database")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func Create(logger mlogger.Logger, database source.Source, collection string) (*
|
||||
collection = defaultCollection
|
||||
}
|
||||
db := &DB{
|
||||
repo: repository.CreateRepository(database, collection),
|
||||
repo: repository.CreateMongoRepository(database, collection),
|
||||
logger: logger.Named("rate_limits"),
|
||||
}
|
||||
if err := db.ensureIndexes(); err != nil {
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Second-factor service
|
||||
|
||||
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40),
|
||||
[PR #41](https://git.sendico.io/tech/sxchnge/pulls/41), branch `twofactor-40`.
|
||||
|
||||
## Ownership
|
||||
|
||||
`backend/product/twofactor` is a domain service inside the product executable.
|
||||
It owns verified factors, preferred delivery channels, challenge snapshots,
|
||||
issuance limits, code verification and single-use authorization proofs.
|
||||
Its domain has no dependency on auth HTTP, JWTs or sessions. Shared Mongo
|
||||
wrappers supply persistence and transactions; notifications only deliver messages.
|
||||
|
||||
Auth verifies the password, account and client, creates a pending JWT bound to the
|
||||
client/device, and passes its hash as the login operation context. The service
|
||||
selects the account's verified preferred factor and returns a masked challenge.
|
||||
Auth calls `Complete` with the code and a callback that creates the session.
|
||||
The service verifies the code, issues and consumes a proof, and commits the
|
||||
callback's database changes in one transaction. Rejected guesses commit attempt
|
||||
counters; callback failures roll back the code, proof and session together.
|
||||
The HTTP response is written only after commit.
|
||||
|
||||
`Verify` and `Consume` also support separate domain steps for an operation that
|
||||
needs a short-lived proof. Proofs bind the customer, purpose, operation and
|
||||
challenge; they expire within two minutes and never outlive the challenge.
|
||||
`Consume` checks the current profile and consumes the proof with the owner's
|
||||
callback. Callbacks must only use this shared Mongo transaction: no external I/O,
|
||||
response writes or irreversible side effects. Mongo may retry the callback.
|
||||
An eventual remote service would need an explicit distributed completion protocol;
|
||||
the callback contract cannot simply be translated into an RPC.
|
||||
|
||||
Signup email activation remains in `auth/flow`, with separate purpose and link
|
||||
semantics. API-client secret login retains its existing separate behavior.
|
||||
No generic public endpoint can choose an arbitrary authorization purpose.
|
||||
|
||||
## Sendico reuse
|
||||
|
||||
The token engine is the existing locally owned copy in
|
||||
`backend/product/verification/store`. Its source is Sendico
|
||||
`S_I/sendico` commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`, already recorded
|
||||
in `backend/PROVENANCE.json` and `backend/PROVENANCE.md`. The new service uses
|
||||
`NewOTPRequest`, `NewLinkRequest`, `CreateWithResult`, `Consume`, context binding,
|
||||
idempotency, cooldown, max retries and issuance limits from that engine. It
|
||||
retains SXCHNGE's subsequent random-key, empty-target-query and safe-log fixes.
|
||||
No alternative OTP generator or token-consumption database was introduced.
|
||||
|
||||
The committed Sendico handlers inspected under
|
||||
`api/edge/product/internal/server/verificationimp/` include `request.go`,
|
||||
`verify.go`, `sendcode.go`, `target.go`, `service.go`, `types.go` and `token.go`.
|
||||
They mix verification with session creation and private platform messaging and
|
||||
allow recipient resolution unsuitable for a verified-factor boundary. Those
|
||||
handlers were not copied. The orchestration, profile store, delivery interface
|
||||
and HTTP settings adapter are new local code. There is no Sendico runtime,
|
||||
private messaging or database dependency.
|
||||
|
||||
## Factors and policy
|
||||
|
||||
An active account's activated primary email is the initial verified factor.
|
||||
Profiles are persisted in `two_factor_profiles`; challenge snapshots in
|
||||
`two_factor_challenges` bind the chosen factor, locale, profile revision and
|
||||
operation deadline. Snapshots cannot redirect a code during resend. Profile
|
||||
changes invalidate outstanding challenges and proofs. A transactional profile
|
||||
write serializes proof consumption against concurrent preference changes.
|
||||
Challenge documents expire through a TTL index one day after their logical expiry;
|
||||
logical expiry is checked synchronously and does not depend on TTL cleanup.
|
||||
No existing customer migration is required: the initial profile is resolved
|
||||
from the verified account and persisted on first completed verification.
|
||||
|
||||
Email is the installed channel driver. Users can enroll an additional email,
|
||||
prove ownership and select it. Selection requires a fresh code at the current
|
||||
preferred factor; enrollment alone cannot replace it. At most five factors
|
||||
(including the primary email) are enrolled. Registered drivers form the supported
|
||||
channel allow-list; unsupported or unavailable channels fail closed. There is no
|
||||
automatic fallback, disable endpoint, factor removal or account recovery flow.
|
||||
SMS, push, authenticator apps and passkeys require concrete implementations and
|
||||
client support before use. The Flutter settings screen is still the existing
|
||||
preview; these authenticated settings APIs are ready for a separate UI adapter.
|
||||
|
||||
Login, payout and preference scopes are recognized internally. Payout integration
|
||||
is not added here; the payout owner must validate its immutable operation details
|
||||
and call this service. An auth proof cannot authorize money operations.
|
||||
|
||||
## HTTP settings contract
|
||||
|
||||
All routes below are under `/api/v1/accounts` and require an existing active
|
||||
account session through the standard `AccountHandler`. Pending login credentials
|
||||
cannot access them. Account identity and operation scope come from the server,
|
||||
not request fields. Responses use the existing `data` envelope and `no-store`.
|
||||
|
||||
| Method/path | Body | Result (202) |
|
||||
| --- | --- | --- |
|
||||
| GET `/two-factor` | none | `required: true`, `preferred`, `factors: [{id, channel, target}]` |
|
||||
| POST `/two-factor/enroll` | `channel`, `address`, `idempotencyKey` | Challenge at the proposed address |
|
||||
| POST `/two-factor/enroll/verify` | `challengeId`, `code` | Settings after ownership verification |
|
||||
| POST `/two-factor/preference` | `factorId`, `idempotencyKey` | Challenge at the current preferred factor |
|
||||
| POST `/two-factor/preference/verify` | `factorId`, `challengeId`, `code` | Settings after preference changes |
|
||||
| POST `/two-factor/resend` | `challengeId`, `idempotencyKey` | Same settings operation and target, renewed code |
|
||||
|
||||
Challenge responses contain `challengeId`, `channel`, masked `target`, `expiresAt`
|
||||
and `resendAt`. Login responses additionally include the pending JWT; login
|
||||
resend/verify now require both `challengeId` and `pendingToken`. Codes, raw proofs
|
||||
and full enrolled addresses are never returned in these responses.
|
||||
|
||||
Codes expire within ten minutes with five guesses per issued code. New issuance
|
||||
uses a one-minute cooldown per challenge, plus shared fixed-window limits of five
|
||||
per customer and twenty per requesting IP per hour across purposes. New challenges
|
||||
cannot reset these delivery budgets. Same-key delivery retries recover the same
|
||||
code, notification key and expiry; notification enqueueing deduplicates them.
|
||||
A new accepted resend invalidates the prior code. A locked/expired operation must
|
||||
restart. Replaying a used code or proof cannot execute the callback again.
|
||||
|
||||
## Rollout and recovery
|
||||
|
||||
Load revision 1 of `twofactor.code` alongside `auth.activation` and `auth.login`
|
||||
from `backend/notifications/templates.example.json`; all have English/Russian
|
||||
variants. Existing notification TLS/caller configuration and the private signing
|
||||
key supply delivery and secret-derived issuance keys. No new service deployment
|
||||
or network credentials are required. Mongo must support transactions (replica set).
|
||||
The application creates the two new collections/index on startup.
|
||||
|
||||
Deploy backend and Flutter contract changes together. Earlier pending login codes
|
||||
have no new challenge record and must restart password login after rollout.
|
||||
Existing sessions and activation links are preserved. Key rotation invalidates
|
||||
outstanding factor snapshots/proofs. Keep factors on an available driver when
|
||||
changing configured drivers; there is no silent fallback.
|
||||
|
||||
A failed session write permits retrying the same code. A lost HTTP response after
|
||||
a successful commit cannot replay the operation: restart login. Enrollment and
|
||||
preference confirmation can be reconciled with GET settings after an ambiguous
|
||||
response. Tokens and challenges commit before notification Send; a product crash
|
||||
before Send still requires resend. No product delivery outbox or inbox receipts
|
||||
are claimed. No live emails were sent during tests.
|
||||
|
||||
## Verification and observability
|
||||
|
||||
Real Mongo tests cover failed delivery replay, scope/customer isolation, expiry,
|
||||
inactive accounts, attempts committed through outer transactions, proof replay,
|
||||
callback rollback, concurrent completion, enrollment and preference invalidation.
|
||||
The signup/login integration test exercises the new auth boundary. Transport
|
||||
checks reject caller identity/purpose/destination overrides. Architecture tests
|
||||
prevent the 2FA domain importing auth or sessions. Flutter adapter tests exercise
|
||||
server challenge binding, resend recovery and unsupported channels.
|
||||
|
||||
Existing auth HTTP counters/timers and frontend journey events remain authoritative
|
||||
for login outcomes. Domain logs record queued challenges, rejected codes and
|
||||
completed operations with challenge IDs and purpose/channel, without codes,
|
||||
proofs, keys or destinations. Prepared proof logs are debug-level and do not
|
||||
represent a committed operation. See `docs/ux/auth-journeys.md` for attempt windows,
|
||||
retry/abandonment rules and the existing production analytics export gap.
|
||||
No new settings UI journey or production analytics sink is claimed.
|
||||
|
||||
The UI composition is unchanged. The shared DTO/mapper/service adapter preserves
|
||||
masked delivery targets, provider error/retry handling and in-memory credential
|
||||
storage. Existing widget tests cover mobile forms, keyboard behavior, loading,
|
||||
cooldown and recovery; new adapter tests cover the changed backend contract.
|
||||
Required completion checks and delivery are recorded in `docs/implementation.md`.
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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.
|
||||
The initial integration is merged. Task #40 separates login verification into
|
||||
the [second-factor service](architecture/two-factor.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
Load the `auth.activation`, `auth.login` and `twofactor.code` revision 1 templates from
|
||||
`backend/notifications/templates.example.json` into the notification catalog.
|
||||
These templates have English and Russian variants; product account locale selects
|
||||
Russian for `ru` variants and English otherwise. Publish subsequent template
|
||||
changes under a new revision rather than mutating deployed content.
|
||||
|
||||
Configure `api.notifications` in the product YAML:
|
||||
|
||||
- `address`: internal notifications gRPC address.
|
||||
- `ca_file`: optional trusted PEM CA bundle for explicit TLS. Omit both TLS settings for plaintext.
|
||||
- `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 private notifications endpoint uses the existing product storage namespace
|
||||
without caller authentication. Keep Mongo, 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; configure Mongo, NATS and email credentials separately.
|
||||
|
||||
The notification service must be reachable on the private network, 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}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
|
||||
| POST `/login/resend` | `pendingToken`, `challengeId`, `idempotencyKey` | 202 with the challenge timing and same pending credential |
|
||||
| POST `/login/verify` | `pendingToken`, `challengeId`, six-digit `code` | 200 with account and existing access/refresh response |
|
||||
|
||||
The pending JWT binds the customer, client and device. The verification record
|
||||
binds its code to a hash of that JWT. Password login does not create a session.
|
||||
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 customer and twenty per requesting IP per hour
|
||||
across second-factor purposes. Incorrect codes
|
||||
consume the existing five-attempt budget. Restart login after a pending credential
|
||||
expires or a challenge locks. Signup/resend request limits also apply per email
|
||||
(10/hour) and IP (30/hour), using shared atomic fixed-window counters.
|
||||
|
||||
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 verification, one-use proof consumption and session creation now commit in
|
||||
one transaction. A session-write failure leaves the code retryable; a lost HTTP
|
||||
response after commit requires restarting password login. The adapter
|
||||
retains a confirmed session in memory and has no refresh/restore-on-reload flow
|
||||
in this contribution. Backend authorization remains authoritative for future
|
||||
protected APIs. Notification provider failures after queue acceptance are visible
|
||||
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,294 +497,3 @@ 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.
|
||||
|
||||
## Dedicated second-factor service (#40)
|
||||
|
||||
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`,
|
||||
based on main after merged PR #39. Primary module: twofactor; secondary modules:
|
||||
auth, verification purpose definitions, notification templates and Flutter auth.
|
||||
|
||||
`product/twofactor` now owns challenge issuance/delivery, verified destinations,
|
||||
preferences, attempt/cooldown limits and single-use proofs. It reuses the
|
||||
Sendico-derived verification store at the existing pinned revision. No new
|
||||
Sendico handler or private messaging code was copied. Auth owns credentials,
|
||||
pending JWTs and session creation; signup activation remains in `auth/flow`.
|
||||
The service's `Complete` transaction commits code verification, proof consumption
|
||||
and the owner's session write together. Review fixed rejected-attempt rollback,
|
||||
ensured failed session writes preserve retryability, retained activation replay
|
||||
key compatibility and removed the unused verification-store injection from auth
|
||||
HTTP. A profile write serializes consumption against preference changes.
|
||||
|
||||
Authenticated APIs enroll additional verified emails and change the preferred
|
||||
factor only after verifying the current factor. Unsupported channels fail closed.
|
||||
Flutter's shared DTO/mapper/service adapter sends the server challenge ID with
|
||||
the pending JWT, preserves resend keys after failure and rejects unknown channels.
|
||||
No widgets or provider/analytics boundaries changed. Existing login journey
|
||||
measurement and error/recovery UI remain in use; settings UI integration is not
|
||||
part of this separation.
|
||||
|
||||
Validation passed: `make test` (all Go race suites and `make test-frontend`),
|
||||
`make lint-backend`, `flutter analyze` in `frontend/packages/sxchnge_auth`, and
|
||||
Dart format verification for all four changed Dart files. New real Mongo tests
|
||||
cover single-use proofs, customer/purpose/context isolation, delivery retries,
|
||||
committed failed guesses, expiry, inactive accounts, operation rollback,
|
||||
concurrent completion, verified enrollment, preference selection and old-proof
|
||||
invalidation. The updated signup/login test covers the full auth boundary;
|
||||
transport and architecture tests cover scope injection and domain dependencies.
|
||||
Frontend tests cover challenge binding, resend recovery and unsupported channels.
|
||||
No live email or optional security scan was run.
|
||||
|
||||
See [ownership, Sendico provenance, HTTP contract and rollout](architecture/two-factor.md).
|
||||
Deploy backend and client together, load `twofactor.code` revision 1, and restart
|
||||
previously pending logins after rollout. Email is the implemented channel.
|
||||
Existing settings screens still use preview adapters; SMS/push/TOTP/passkeys,
|
||||
factor removal/recovery, payout integration and a separate process are not
|
||||
implemented. Successful-commit response loss still requires login restart;
|
||||
notification queue acceptance is not inbox delivery.
|
||||
|
||||
Delivered in [PR #41](https://git.sendico.io/tech/sxchnge/pulls/41) against `main`.
|
||||
Branch `twofactor-40` is pushed. The PR is left open for review; no merge was performed.
|
||||
|
||||
## Vault-backed local development (#42)
|
||||
|
||||
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
||||
Primary module: dev environment; secondary scope: container packaging, root build
|
||||
commands, current-stable backend test images and repository container policy.
|
||||
|
||||
- Added a dedicated `sxchnge-local` Compose project with persistent Vault, an
|
||||
authenticated MongoDB replica set, NATS JetStream and build definitions for
|
||||
notifications, BFF and Flutter/Nginx. Existing Sendico containers and volumes
|
||||
remain independent.
|
||||
- `make build` resolves current stable releases and pins manifest digests in
|
||||
ignored local state. Flutter is built from its stable source tag/commit. All
|
||||
protobuf bindings and frontend output are generated within the build, never
|
||||
committed. Root `AGENTS.md` now records the stable-container policy.
|
||||
- Bootstrap configures Vault KV/PKI/AppRole and `mongodb-database-plugin`, rotates
|
||||
its Mongo manager credential and issues separate database roles for the BFF
|
||||
and notifications. The shared database wrapper replaces credential pools in
|
||||
process and drains transactions/cursors before revoking retired credentials.
|
||||
Agent handles non-database secrets. Shared messaging paths remain intact.
|
||||
- Resend is the default. The user explicitly chose to provision its API key and
|
||||
verified sender manually after bootstrap. `make up` leaves healthy infrastructure
|
||||
running and intentionally exits nonzero before product startup while those
|
||||
fields are empty. No placeholder credential or disabled driver was retained.
|
||||
- Frontend HTTPS and notification TLS use Vault PKI. The frontend proxies the BFF
|
||||
at the same origin and builds with the existing real auth adapter and a seeded
|
||||
public browser client. No UI or business service code changed.
|
||||
|
||||
Validation: `make build` built all product images; `make test` and
|
||||
`make lint-backend` passed. Backend tests pull fresh Mongo/NATS stable images and
|
||||
use `-count=1` to prevent Go's test cache from hiding an image update.
|
||||
`make test-dev-infra` passed its five setup regression tests and live checks for
|
||||
AppRole restrictions, database issuance, cross-database denial, renewal and
|
||||
revocation. `docker compose ... config --quiet` and `git diff --check` passed.
|
||||
The initial image build exposed missing protobuf headers; installing
|
||||
`protobuf-dev` in the build stage fixed it.
|
||||
|
||||
Additional live checks passed: a down/up cycle preserved a Mongo marker and Vault
|
||||
secrets and automatically unsealed Vault; changing/restoring the NATS token
|
||||
restarted the NATS child; the initial restart-based database credential lifecycle was superseded by
|
||||
the uninterrupted pool rotation implementation described below.
|
||||
Temporary BFF/frontend containers served the web bundle and proxied BFF health
|
||||
successfully over HTTPS verified with the generated CA; those test containers
|
||||
were removed. The notifications image rendered its Agent configuration and exited
|
||||
with the expected missing email API key error. No real email was sent.
|
||||
|
||||
The final `make build && make up` is expected to exit at the Resend provisioning
|
||||
check until the user populates `kv/email`. Full six-service readiness and a live
|
||||
Resend signup/login were not asserted without that credential. Vault, Mongo and
|
||||
NATS remain running. No optional security scan or merge was performed. See the
|
||||
[local development guide](local-development.md) for exact provisioning fields,
|
||||
certificate trust, restart behavior, local bootstrap-material handling and
|
||||
verification commands.
|
||||
|
||||
### Uninterrupted Mongo credential rotation
|
||||
|
||||
BFF and notifications now acquire database operation scopes from the shared
|
||||
wrapper. A replacement is issued at half the effective token/lease lifetime,
|
||||
validated before publication, and independently revoked after draining. Whole
|
||||
transactions and cursors retain their original pool. Failed refreshes retry while
|
||||
the current credential remains valid. Expiry rejects new operations until recovery;
|
||||
there is no scheduled service restart. Non-database Agent template changes still
|
||||
restart their child process. This is planned-rotation continuity, not infrastructure
|
||||
high availability or a guarantee beyond Vault credential expiry.
|
||||
|
||||
The repository and store constructor contract now uses `source.Source` rather
|
||||
than a concrete Mongo database. Static test integrations use `source.Fixed`.
|
||||
Application code continues through shared repositories and transaction factories.
|
||||
The new `make test-dev-rotation` command proves real Vault plugin replacements
|
||||
under BFF transaction traffic and checks process identity. It restores shortened
|
||||
role TTLs and removes temporary product containers afterward.
|
||||
|
||||
Validation of the revision: `make build`, `make test`, `make lint-backend`,
|
||||
`make test-dev-infra` (six setup tests and live checks), and
|
||||
`make test-dev-rotation` passed. The live rotation check observed three replacements
|
||||
with an unchanged BFF PID and 1,155 expected invalid-token HTTP responses, zero
|
||||
failures. Product integration-tag packages also compiled. The race-enabled
|
||||
rotation suite covers active transactions/cursors, rejected credentials, transient
|
||||
and extended outages, automatic recovery, rollback and shutdown; issuer tests
|
||||
cover effective lifetime, credential escaping and failed-issuance cleanup.
|
||||
|
||||
An additional `go test -race -tags integration ./database/internal/repository/...
|
||||
./database/vault/...` run in `backend/shared` found two existing test issues: the
|
||||
partial-index test expects nested BSON maps instead of ordered documents, and the
|
||||
patch timestamp assertion can require a strict increase within the same Mongo
|
||||
millisecond. The BSON mismatch reproduces against `origin/main`; five isolated baseline
|
||||
repetitions of the timestamp test pass, consistent with its timing-dependent
|
||||
assertion. Neither assertion nor the timestamp update logic changes in this task;
|
||||
the supplementary failures are recorded and are not suppressed. The required
|
||||
normal test and lint commands pass. No optional security scan or email delivery
|
||||
was performed. Resend remains intentionally unconfigured; infrastructure stays up
|
||||
and product startup awaits manual `kv/email` provisioning.
|
||||
|
||||
### Make and shell development tooling
|
||||
|
||||
The local development tooling now runs POSIX-style shell scripts in a Docker
|
||||
image containing Docker CLI, curl, jq, Git, OpenSSL, flock and ShellCheck. All eight
|
||||
Python scripts were removed. Make remains the entry point; `make email` replaces
|
||||
the Python credential prompt. Host requirements are Make, shell and local Docker
|
||||
with Compose/BuildKit. The tool image is built from a freshly pulled stable Docker
|
||||
CLI digest; application/infrastructure stable resolution and atomic local pins
|
||||
remain intact. Tooling mounts the local Docker socket and workspace at its host
|
||||
path; remote Docker engines are unsupported.
|
||||
|
||||
Static Agent HCL sources replace configuration generation and contain only Vault
|
||||
template expressions. Bootstrap preserves existing Vault state and credentials,
|
||||
protects its private files, and fails when Resend credentials are absent. The shell
|
||||
infrastructure check now explicitly validates real Mongo transaction commit and
|
||||
rollback with each application's Vault-issued identity. The rotation command
|
||||
retains its unchanged-process and expected-response checks and TTL restoration.
|
||||
|
||||
Validation of the shell replacement passed: `make build`, `make test-dev-tooling`,
|
||||
`make test-dev-infra`, Compose configuration validation and `git diff --check`.
|
||||
An isolated `sxchnge-shell-check` project passed first bootstrap, live transaction
|
||||
and lease checks, and repeated bootstrap; its containers, volumes and temporary
|
||||
state were removed. `make test-dev-rotation` passed three real Vault replacements
|
||||
with unchanged BFF PID, 1,048 expected HTTP responses and zero failures; role TTLs
|
||||
were restored and temporary BFF/frontend containers removed. `make up` exits at
|
||||
the intentional missing-Resend check, with infrastructure healthy. Backend and
|
||||
frontend code did not change in this revision; their earlier test/lint results
|
||||
and supplementary legacy test limitations above still apply. No email was sent.
|
||||
|
||||
### Organized infrastructure and Vault ownership
|
||||
|
||||
This revision supersedes the earlier local Nginx/Vault-certificate arrangement.
|
||||
Local Compose, runtime configuration, startup scripts and verification now live
|
||||
under `infra/local/`, grouped by service and responsibility. Dockerfiles live in
|
||||
`infra/images/`; the root Make targets remain the entry point. Product source
|
||||
stays in backend/frontend. Generated state remains in ignored `.local/dev/`.
|
||||
|
||||
Vault KV now contains `services/bff/jwt`,
|
||||
`services/notifications/callers/bff`, `services/notifications/email/resend`,
|
||||
`infrastructure/nats/auth` and `bootstrap/mongo`. Dynamic database credentials
|
||||
remain in the database engine. Migration copies with CAS protection, compares
|
||||
existing values, archives readable old versions and metadata privately, and then
|
||||
removes old flat/TLS KV entries. Conflicting destinations stop migration without
|
||||
removing sources. Policies and Agent templates reference only the new paths.
|
||||
The unused existing PKI mount is retained to avoid destroying its signing key;
|
||||
new bootstrap neither creates nor uses it.
|
||||
|
||||
Caddy replaces Nginx, serving Flutter and proxying `/api` to BFF. Caddy owns browser
|
||||
certificates and its CA in persistent volumes; only its public CA is exported to
|
||||
`.local/dev/ca.pem`. Existing browser trust must be updated to that new CA. Local
|
||||
BFF-to-notifications uses plaintext authenticated gRPC. Optional explicitly
|
||||
configured TLS remains available in the reusable client/server; incomplete TLS
|
||||
configuration is rejected. No local service consumes TLS secrets from Vault.
|
||||
|
||||
Validation passed: `make build`, `make test`, `make lint-backend`,
|
||||
`make test-dev-tooling`, `make test-dev-infra`, and Compose/diff checks. Tests cover
|
||||
plaintext caller-token transport, optional verified TLS, invalid certificate
|
||||
configuration and secret migration conflicts. The existing Vault was migrated
|
||||
and canonical paths/absence of old entries verified. An isolated fresh project
|
||||
passed transaction/lease checks and repeated bootstrap; disposable volumes and
|
||||
state were removed. The final rotation check also verified Caddy static serving,
|
||||
SPA fallback and API health, then three pool replacements with unchanged BFF PID
|
||||
and 1,083 expected HTTP responses, zero failures. Role TTLs were restored and
|
||||
temporary BFF/frontend containers removed. `make up` intentionally stops at the
|
||||
missing Resend credential check; no real email, optional security scan or merge
|
||||
was performed. Earlier supplementary legacy test limitations remain recorded.
|
||||
|
||||
Mongo bootstrap credentials now live at `kv/infrastructure/mongo/bootstrap`,
|
||||
consistent with component ownership. The live `kv/bootstrap/mongo` value was
|
||||
copied, compared and archived before removing the old path. Bootstrap and admin
|
||||
checks read the new path; application identities are denied both paths. Shell
|
||||
checks and live infrastructure validation (including Mongo transactions, lease
|
||||
renewal/revocation and obsolete-path absence) passed. `make up` still stops at the
|
||||
expected missing-Resend check. No backend code or product behavior changed.
|
||||
|
||||
Email configuration is now separated from credentials. The current Vault secret
|
||||
`kv/services/notifications/email/resend` contains only `api_key`; `driver`,
|
||||
`sender_address` and `sender_name` live in
|
||||
`infra/local/notifications/runtime.yml`. Sender address is empty until configured.
|
||||
Agent injects only `EMAIL_API_KEY`, and notifications loads its runtime YAML
|
||||
directly. `make email` prompts only for the API key. The migration preserves the
|
||||
key with a CAS-checked update, archives the prior readable version, and refuses
|
||||
unknown fields rather than discarding them. The live Vault entry was normalized.
|
||||
|
||||
Validation passed: image build, shell/migration tests, live infrastructure checks
|
||||
(including key-only secret shape and Mongo transactions/leases), and diff checks.
|
||||
`make up` intentionally fails for the still-missing API key. Verified sender must
|
||||
also be configured in runtime YAML before rebuilding and starting notifications.
|
||||
No backend source changed and no email was sent.
|
||||
|
||||
### Task #42: remove internal notification caller authentication
|
||||
|
||||
Removed BFF bearer metadata, token configuration/validation, and the notifications
|
||||
authentication interceptor. The private gRPC endpoint uses the existing `product`
|
||||
storage namespace, preserving notification receipts and idempotency without a
|
||||
database migration. NATS authentication and application/user authentication remain.
|
||||
|
||||
Removed the caller secret from bootstrap, Vault Agent templates and policies.
|
||||
Bootstrap archives and retires both `caller` and
|
||||
`services/notifications/callers/bff`; live Vault no longer contains either path.
|
||||
Service policies deny both paths. The retirement regression fixture verifies
|
||||
repeatability and preservation of the NATS authentication secret.
|
||||
|
||||
Validation: `make build`, `make test`, `make lint-backend`,
|
||||
`make test-dev-tooling`, `make test-dev-infra`, and `git diff --check`.
|
||||
Tests cover credential-free Send/Get, idempotent replay through the real service,
|
||||
and absence of outgoing bearer metadata over plaintext and optional TLS.
|
||||
`make up` applied the live migration and intentionally stopped at the missing
|
||||
Resend API key. No email was sent; full product deployment still awaits the API
|
||||
key and configured sender. Delivery continues on `dev-env-42` in
|
||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43).
|
||||
@@ -1,197 +0,0 @@
|
||||
# Local development
|
||||
|
||||
Task [#42](https://git.sendico.io/tech/sxchnge/issues/42),
|
||||
[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43), branch `dev-env-42`.
|
||||
|
||||
## Build and start
|
||||
|
||||
Requires Make, a POSIX shell and a local Docker engine with Compose v2 and
|
||||
BuildKit. No host Python, Go, Flutter, jq or curl is required. Make runs bootstrap
|
||||
tooling in Docker with the local Docker socket and workspace mounted. Remote
|
||||
Docker engines are unsupported.
|
||||
|
||||
```sh
|
||||
make build && make up
|
||||
```
|
||||
|
||||
The build resolves the latest stable releases and pins their digests in ignored
|
||||
`.local/dev/images.env`. Prereleases are excluded, resolution failures stop the
|
||||
build, and previous pins are retained on failure. Flutter is pinned to the stable
|
||||
release commit. Tooling uses a freshly pulled stable Docker CLI digest. Generated
|
||||
protobuf bindings and Flutter output are built locally/in Docker and never tracked.
|
||||
|
||||
Compose is at `infra/local/compose.yml`; Make supplies the repository root as its
|
||||
project directory. Dockerfiles are in `infra/images/`. Runtime and startup files
|
||||
are grouped by service under `infra/local/`. The stack consists of Vault, Mongo,
|
||||
NATS, notifications, BFF and Caddy serving the frontend.
|
||||
|
||||
On first startup, bootstrap initializes/unseals persistent Vault, provisions
|
||||
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
|
||||
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
|
||||
until Resend credentials exist. Infrastructure remains available for provisioning.
|
||||
|
||||
After all services pass their startup health checks and the public Caddy CA is
|
||||
exported, `make up` prints a Sendico-style completion message with the frontend,
|
||||
API, API health and Vault URLs, the CA trust reminder, and status/log/stop commands.
|
||||
Failed startup does not print the success message. Mongo and NATS are private
|
||||
Compose services, so the summary does not advertise host URLs or credentials for
|
||||
them. This terminal-only change reuses Compose errors and the command exit status
|
||||
as startup outcome signals; it adds no analytics or secret output.
|
||||
|
||||
Completion-message validation: `make test-dev-tooling` and `git diff --check`
|
||||
passed. A containerized shell preview with stubbed infrastructure calls confirmed
|
||||
the success layout and that failed service startup or CA export exits nonzero
|
||||
without the message. The UX review checked readable plain-text labels, actionable
|
||||
URLs/commands and confirmed-success timing. This check did not restart the live
|
||||
stack or verify application readiness.
|
||||
|
||||
## Vault layout
|
||||
|
||||
Paths below are inside the KV v2 mount `kv`; the API inserts `data/` after `kv/`.
|
||||
|
||||
```text
|
||||
kv/
|
||||
├── services/
|
||||
│ ├── bff/jwt # jwt
|
||||
│ └── notifications/
|
||||
│ └── email/resend # api_key only
|
||||
└── infrastructure/
|
||||
├── nats/auth # token; read by NATS and notifications
|
||||
└── mongo/bootstrap # local administrator and replica-set bootstrap material
|
||||
```
|
||||
|
||||
Every secret has an owning service or infrastructure component. Service policies
|
||||
permit only the exact paths they consume. Applications cannot read bootstrap
|
||||
credentials. Mongo application users are dynamic secrets under
|
||||
`database/creds/bff` and `database/creds/notifications`, not KV entries. They have
|
||||
`readWrite` only on their respective `sxchnge` / `sxchnge_notifications` databases.
|
||||
Vault uses `mongodb-database-plugin` and its own `vault-manager` account, whose
|
||||
password is rotated immediately after initial configuration.
|
||||
|
||||
There are no frontend certificates, notification certificates or generic `tls`
|
||||
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
|
||||
on the trusted private Docker network without caller authentication.
|
||||
|
||||
## Resend
|
||||
|
||||
Open [Vault](http://127.0.0.1:18200) and edit
|
||||
`kv/services/notifications/email/resend`:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `api_key` | Your Resend API key |
|
||||
|
||||
The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
driver: resend
|
||||
settings:
|
||||
api_key_env: EMAIL_API_KEY
|
||||
sender_address: "your-verified-sender@example.com"
|
||||
sender_name: "SXCHNGE"
|
||||
```
|
||||
|
||||
Set your verified address in that file and rebuild. It is intentionally empty by
|
||||
default, so notifications cannot start with an unconfigured sender. Vault Agent
|
||||
injects only the API key; it does not generate email configuration.
|
||||
|
||||
Local administrator login material is in private `.local/dev/admin/init.json`.
|
||||
Product containers cannot access it. Alternatively use the hidden terminal prompt:
|
||||
|
||||
```sh
|
||||
make email
|
||||
make build && make up
|
||||
```
|
||||
|
||||
No placeholder key or delivery fallback is installed. Startup validates presence;
|
||||
it does not send an email or validate domain ownership with Resend. Updating the
|
||||
API key causes Agent to restart notifications. Driver/sender changes require an
|
||||
image rebuild. Existing API keys survive bootstrap. Migration removes legacy
|
||||
driver/sender fields from the current Vault secret with a version-checked write;
|
||||
the preceding version is preserved in Vault history and the private archive.
|
||||
|
||||
## Browser HTTPS and internal connections
|
||||
|
||||
Caddy serves Flutter files and proxies `/api/*` to BFF over HTTP. Open
|
||||
[https://localhost:5126](https://localhost:5126). HTTPS preserves Secure/HttpOnly
|
||||
cookies and same-origin API access. Caddy uses `tls internal` and manages its local
|
||||
CA, certificates and renewal in the `caddy-data` volume. The `caddy-config` volume
|
||||
stores Caddy runtime configuration. Neither volume nor Caddy has Vault access.
|
||||
|
||||
After frontend starts, Make exports only the public Caddy CA to `.local/dev/ca.pem`.
|
||||
Trust it in your browser/OS to avoid certificate errors. This is a new CA when
|
||||
migrating from Vault-issued browser certificates; the old CA will not trust it.
|
||||
Trust installation is not automated. A CLI check can use:
|
||||
|
||||
```sh
|
||||
curl --cacert .local/dev/ca.pem https://localhost:5126/api/v1/health
|
||||
```
|
||||
|
||||
BFF calls notifications over plaintext gRPC without caller authentication. Mongo and NATS
|
||||
also stay within the private Compose network; no internal service publishes a host
|
||||
port. Only frontend and Vault bind host loopback ports. TLS remains optional in
|
||||
the reusable notification client/server for deployments that explicitly configure
|
||||
it; local runtime configuration contains no certificate settings.
|
||||
|
||||
Caddy behavior follows its [internal TLS](https://caddyserver.com/docs/caddyfile/directives/tls)
|
||||
and [automatic HTTPS](https://caddyserver.com/docs/automatic-https) documentation.
|
||||
|
||||
## Mongo transactions and credential rotation
|
||||
|
||||
Mongo is an authenticated single-node replica set (`rs0`), supporting multi-document
|
||||
transactions. It has no failover or redundant data copy. A Mongo restart interrupts
|
||||
access even though application credential replacement does not.
|
||||
|
||||
The shared database wrapper authenticates with each service's AppRole and obtains
|
||||
an independent token/lease for each pool. At half the shorter token/lease lifetime,
|
||||
it verifies a replacement pool before publishing it. Active operations, cursors
|
||||
and full transactions retain the old pool until they finish, then it closes and
|
||||
its token is revoked. Nested stores and commit retries keep the same pool.
|
||||
|
||||
Failed refreshes retain valid credentials and retry with bounded backoff. After
|
||||
expiry, database operations fail closed until automatic recovery. Administrative
|
||||
revocation, infrastructure outages and operations longer than their lease can
|
||||
still interrupt work. Repository consumers use `source.Source` operation scopes;
|
||||
`source.Fixed` adapts externally owned static/test connections.
|
||||
|
||||
## Migration and persistence
|
||||
|
||||
Bootstrap migrates the old flat `bff`, `email` and `nats` paths into the
|
||||
hierarchy above, and moves `bootstrap/mongo` to `infrastructure/mongo/bootstrap`.
|
||||
It copies with CAS protection, compares values, and refuses to
|
||||
overwrite conflicting destinations. After consumers use the new paths, it removes
|
||||
old KV metadata and obsolete `tls`, `frontend-tls`, `notifications-tls` entries.
|
||||
Readable old version history and metadata are archived privately in
|
||||
`.local/dev/migrations/organized-v1/` before removal. That archive is administrative
|
||||
recovery material; applications do not mount it. Old deleted/destroyed versions
|
||||
are not recreated. The old PKI engine is not used or provisioned by this stack;
|
||||
an existing PKI mount is retained to avoid destroying its non-exportable signing
|
||||
key and history as part of a KV reorganization.
|
||||
|
||||
Vault, Mongo, JetStream and Caddy data use project-scoped named volumes. Private
|
||||
bootstrap material lives in `.local/dev/`. `make down` preserves both. Vault's local
|
||||
unseal file allows automatic unsealing on restart. This local convenience and
|
||||
loopback HTTP Vault endpoint are for development. Keep bootstrap files paired
|
||||
with their matching volumes; do not discard one side independently.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
make ps
|
||||
make logs
|
||||
make down
|
||||
make test-dev-tooling # shell analysis, failure paths, migration conflict checks
|
||||
make test-dev-infra # policy isolation, real Mongo transactions and lease lifecycle
|
||||
make test-dev-rotation # temporarily deploy BFF/frontend and exercise short leases
|
||||
make test-dev # requires all six services; verified frontend/API HTTPS
|
||||
```
|
||||
|
||||
Rotation validation temporarily shortens the BFF role TTL, recreates BFF/frontend,
|
||||
and requires three replacements with unchanged BFF PID and zero unexpected
|
||||
transaction-backed HTTP responses. It restores TTLs and removes services it
|
||||
started. No verification command sends email. Backend validation remains
|
||||
`make test` and `make lint-backend`, using the host SDKs documented in module guides.
|
||||
|
||||
Bootstrap archives and removes obsolete `caller` and `services/notifications/callers/bff`
|
||||
KV entries after updating Agent policies/templates. NATS authentication remains enabled.
|
||||
@@ -46,6 +46,3 @@ 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/`. 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.
|
||||
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.
|
||||
|
||||
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,7 +19,6 @@ 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,11 +104,6 @@ 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();
|
||||
@@ -334,7 +329,6 @@ class _AuthFormState extends State<AuthForm> {
|
||||
AuthFeedback(state: state, signUp: _signUp),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
if (context.read<TwoFactorService>().isSimulated)
|
||||
TextButton(
|
||||
onPressed: () => context.go('/accounts'),
|
||||
child: Text(l.wEnter),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,860 @@
|
||||
// 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.';
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// 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,11 +43,10 @@
|
||||
"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": "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.",
|
||||
"verificationSent": "We’ve sent you a verification email. Open the link in your inbox to confirm your email address.",
|
||||
"verificationSending": "Preparing your verification email…",
|
||||
"verificationChecking": "Verifying your email…",
|
||||
"verificationVerified": "Your email\nis verified.",
|
||||
|
||||
@@ -43,11 +43,10 @@
|
||||
"authCompleted": "Запрос выполнен.",
|
||||
"authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.",
|
||||
"authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.",
|
||||
"authVerifyEmail": "Подтвердите почту перед входом.",
|
||||
"authUnavailable": "Сервис недоступен. Попробуйте ещё раз.",
|
||||
"authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.",
|
||||
"checkInbox": "Проверьте почту.",
|
||||
"verificationSent": "Если эта почта принадлежит неподтверждённому аккаунту, письмо с подтверждением уже в пути. Откройте ссылку в письме, чтобы подтвердить адрес.",
|
||||
"verificationSent": "Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.",
|
||||
"verificationSending": "Готовим письмо для подтверждения…",
|
||||
"verificationChecking": "Подтверждаем вашу почту…",
|
||||
"verificationVerified": "Ваша почта\nподтверждена.",
|
||||
|
||||
@@ -20,19 +20,13 @@ import 'generated/i18n/app_localizations.dart';
|
||||
Future<void> main() async {
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||
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));
|
||||
runApp(SxchngeApp(localeProvider: locale));
|
||||
}
|
||||
|
||||
class SxchngeApp extends StatefulWidget {
|
||||
const SxchngeApp({
|
||||
super.key,
|
||||
this.localeProvider,
|
||||
this.backend,
|
||||
this.analyticsService,
|
||||
this.identityService,
|
||||
this.limitsService,
|
||||
@@ -44,7 +38,6 @@ class SxchngeApp extends StatefulWidget {
|
||||
this.verificationService = const DummyVerificationService(),
|
||||
});
|
||||
final LocaleProvider? localeProvider;
|
||||
final BackendAuthServices? backend;
|
||||
final AnalyticsService? analyticsService;
|
||||
final IdentityService? identityService;
|
||||
final LimitsService? limitsService;
|
||||
@@ -63,8 +56,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||
|
||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||
late final _factors =
|
||||
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
||||
late final _factors = widget.twoFactorService ?? DummyTwoFactorService();
|
||||
late final _security = SecurityProvider(_factors, _analytics);
|
||||
late final _identity = widget.identityService ?? DummyIdentityService();
|
||||
late final _walletService =
|
||||
@@ -87,18 +79,12 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _journeys = JourneyRecorder(_analytics);
|
||||
late final _services = InstrumentedAuthServices(
|
||||
recorder: _journeys,
|
||||
loginService: GuardedLoginService(
|
||||
widget.backend ?? widget.loginService,
|
||||
_factors,
|
||||
),
|
||||
signupService: widget.backend ?? widget.signupService,
|
||||
verificationService: widget.backend ?? widget.verificationService,
|
||||
loginService: GuardedLoginService(widget.loginService, _factors),
|
||||
signupService: widget.signupService,
|
||||
verificationService: widget.verificationService,
|
||||
);
|
||||
late final _router = createAppRouter(
|
||||
authenticated: widget.backend == null
|
||||
? null
|
||||
: () => widget.backend!.session != null,
|
||||
)..routeInformationProvider.addListener(_routeChanged);
|
||||
late final _router = createAppRouter()
|
||||
..routeInformationProvider.addListener(_routeChanged);
|
||||
String _previousPath = '/';
|
||||
|
||||
@override
|
||||
@@ -123,7 +109,6 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
_wallet.dispose();
|
||||
_security.dispose();
|
||||
_locale.dispose();
|
||||
widget.backend?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,23 +19,12 @@ import '../workspace/shell.dart';
|
||||
import '../workspace/transfer.dart';
|
||||
import 'routes.dart';
|
||||
|
||||
GoRouter createAppRouter({bool Function()? authenticated}) {
|
||||
GoRouter createAppRouter() {
|
||||
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,8 +4,6 @@ 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';
|
||||
@@ -23,63 +21,7 @@ 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,10 +131,7 @@ void main() {
|
||||
);
|
||||
await tap(tester, find.byKey(const ValueKey('submit')));
|
||||
expect(find.text('Check your inbox.'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('a verification email is on its way'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('We’ve sent you'), 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);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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 challengeId => data['challengeId'] as String? ?? '';
|
||||
String get channel => data['channel'] as String? ?? '';
|
||||
String get target => data['target'] as String;
|
||||
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
||||
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
|
||||
String get accessToken =>
|
||||
(data['accessToken'] as Map<String, dynamic>)['token'] as String;
|
||||
String get refreshToken =>
|
||||
(data['refreshToken'] as Map<String, dynamic>)['token'] as String;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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) {
|
||||
if (dto.challengeId.isEmpty || dto.channel != 'email') {
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
}
|
||||
return FactorChallenge(
|
||||
id: id,
|
||||
purpose: FactorPurpose.login,
|
||||
operation: id,
|
||||
target: dto.target,
|
||||
expiresAt: dto.expiresAt,
|
||||
resendAt: dto.resendAt,
|
||||
);
|
||||
}
|
||||
|
||||
static AuthSession session(BackendDTO dto) {
|
||||
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
|
||||
throw const FormatException('Missing session');
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user