16 Commits
Author SHA1 Message Date
Stephan D ce6583f454 Show service links and next steps after local startup 2026-09-13 08:40:24 +02:00
Stephan D caad7a3d5b Remove internal notification caller tokens 2026-09-12 23:59:30 +02:00
Stephan D 1fe173397f Keep email runtime configuration out of Vault 2026-09-12 23:45:58 +02:00
Stephan D 9d57a65010 Group Mongo bootstrap secrets under infrastructure ownership 2026-09-12 23:36:47 +02:00
Stephan D 9dbf0bd80c Organize local infrastructure and Vault secrets by service ownership 2026-09-12 23:30:18 +02:00
Stephan D 1b8800ee73 Replace Python development tooling with Make and containerized shell 2026-09-12 23:14:34 +02:00
Stephan D 23f7b78c3b Keep Mongo operations online across Vault credential rotation 2026-09-12 22:19:36 +02:00
Stephan D 80d6917aa7 Record local environment delivery PR (#42) 2026-09-12 21:05:39 +02:00
Stephan D 184a3be413 Build local product stack with Vault-managed secrets (#42) 2026-09-12 21:04:42 +02:00
tech 8e2ef7aa88 Merge pull request 'Separate second-factor verification and delivery from authentication' (#41) from twofactor-40 into main
Reviewed-on: #41
2026-09-12 18:38:01 +00:00
Stephan D fc33630a0a Record second-factor service delivery PR (#40) 2026-09-12 20:37:09 +02:00
Stephan D ecb63b61c6 Separate second-factor verification and delivery from auth (#40) 2026-09-12 20:36:35 +02:00
tech 12a38cd654 Merge pull request 'Connect signup and login verification to email notifications' (#39) from auth-38 into main
Reviewed-on: #39
2026-09-12 17:46:15 +00:00
Stephan D b6039493c9 docs: prohibit committing autogenerated code 2026-09-12 19:45:07 +02:00
Stephan D 1a079e00cc Integrate latest notifications delivery updates and record PR #39 2026-09-12 15:09:18 +02:00
Stephan D ad5a061164 Integrate email notifications into signup and login (#38) 2026-09-12 15:07:00 +02:00
138 changed files with 6245 additions and 3733 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.local
**/.dart_tool
**/build
**/.env*
**/node_modules
**/*.pb.go
frontend/app/lib/generated
**/.DS_Store
dist
website
**/config.yml
**/tls
**/*.pem
**/*.key
**/__pycache__
+6
View File
@@ -34,3 +34,9 @@ pnpm-debug.log*
/backend/notifications/interface/notifications/**/*.pb.go /backend/notifications/interface/notifications/**/*.pb.go
/backend/notifications/config.yml /backend/notifications/config.yml
/backend/notifications/notifications /backend/notifications/notifications
# Local container pins, Vault bootstrap material and service identities
/.local/
# Python bytecode
__pycache__/
+45
View File
@@ -8,6 +8,36 @@
- For cross-module work, apply every relevant scoped instruction file and - For cross-module work, apply every relevant scoped instruction file and
completion workflow. 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 ## Data and state architecture
Preserve explicit **DTO → mapper → service → data model → provider → UI** Preserve explicit **DTO → mapper → service → data model → provider → UI**
@@ -139,3 +169,18 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
into task commits. Record scope, validation, limitations, and PR links in the into task commits. Record scope, validation, limitations, and PR links in the
task and durable repository documentation. Merge only when authorized by 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. 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.
+48 -4
View File
@@ -5,6 +5,7 @@ test: test-backend test-frontend
.PHONY: test-frontend .PHONY: test-frontend
test-frontend: test-frontend:
cd frontend/app && flutter gen-l10n
cd frontend/packages/sxchnge_wallet && flutter test cd frontend/packages/sxchnge_wallet && flutter test
cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_kyc && flutter test
cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_analytics && flutter test
@@ -16,10 +17,12 @@ test-frontend:
.PHONY: test-backend lint-backend run-backend .PHONY: test-backend lint-backend run-backend
test-backend: generate-notifications test-backend: generate-notifications
cd backend/shared && go test -race ./... docker pull mongo:latest
cd backend/notifications/interface && go test -race ./... docker pull nats:latest
cd backend/notifications && go test -race ./... cd backend/shared && go test -race -count=1 ./...
cd backend/product && go test -race ./... 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 ./...
lint-backend: generate-notifications lint-backend: generate-notifications
cd backend/product && go test ./architecture cd backend/product && go test ./architecture
@@ -48,3 +51,44 @@ package-notifications-interface: generate-notifications
mkdir -p dist mkdir -p dist
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md 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 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
+20 -1
View File
@@ -32,6 +32,24 @@ 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 Flutter packages can be reused by a future mobile app; mobile runners are not
currently included. Local `.vscode/` configuration is ignored. 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 ## Run the consumer app
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
@@ -54,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
repository root: repository root:
```sh ```sh
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web 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
``` ```
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
+8
View File
@@ -71,3 +71,11 @@ code. Auth and verification configurations moved out of application interfaces.
All 112 unconsumed public API snapshot files and their snapshot-only check were All 112 unconsumed public API snapshot files and their snapshot-only check were
removed. Future public adapters will own just the wire types they consume. removed. Future public adapters will own just the wire types they consume.
Backoffice is a separate future executable module, not an alias for product. Backoffice is a separate future executable module, not an alias for product.
## Second-factor separation (#40)
The new `product/twofactor` domain service reuses the already copied
`product/verification/store` issuance and consumption implementation at the pinned
Sendico revision. No additional Sendico source files were copied. Coupled
verification HTTP/session/private-messaging handlers were inspected but excluded.
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
+6 -5
View File
@@ -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 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. JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
each configured caller, and the selected email provider's API key environment variable. Configure a verified sender address and template
variable. Configure a verified sender address, TLS certificate/key and template
catalog. Paths are relative to the process working directory. catalog. Paths are relative to the process working directory.
From the root, run `make run-notifications`. The gRPC listener always uses TLS. 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.
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL 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 are supported by the shared driver. Mongo and messaging both use shared
`DriverConfig` settings; email settings reject unknown keys. `DriverConfig` settings; email settings reject unknown keys.
@@ -46,7 +46,8 @@ Multiple service workers can safely compete for jobs through atomic claims.
## Templates and parameters ## Templates and parameters
See `templates.example.json`. Parameters use Go template map syntax such as See `templates.example.json`, including the `auth.activation` and `auth.login`
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
`{{.first_name}}`. Each revision declares the exact allowed parameter names and `{{.first_name}}`. Each revision declares the exact allowed parameter names and
locale-specific content. Missing/extra parameters, unknown revisions/locales and locale-specific content. Missing/extra parameters, unknown revisions/locales and
invalid content are rejected before acceptance. Empty locale uses the default. invalid content are rejected before acceptance. Empty locale uses the default.
+4 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"io" "io"
"os" "os"
"strings"
"git.sendico.io/tech/sxchnge/notifications/bus" "git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
@@ -22,7 +21,6 @@ type Config struct {
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"` Database db.Config `yaml:"database"`
Templates string `yaml:"templates"` Templates string `yaml:"templates"`
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
Email email.Config `yaml:"email"` Email email.Config `yaml:"email"`
} }
@@ -40,19 +38,14 @@ func Load(path string) (Config, error) {
if err = decoder.Decode(new(any)); err != io.EOF { if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content") return c, merrors.InvalidArgument("unexpected configuration content")
} }
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 { if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
return c, merrors.InvalidArgument("missing notification configuration") 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 { if err = c.Queue.Validate(); err != nil {
return c, err 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 return c, nil
} }
+27 -7
View File
@@ -3,12 +3,10 @@ package app
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestExampleConfigAndSecrets(t *testing.T) { func TestExampleConfigAndSecrets(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml") c, err := Load("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" { if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration") 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") data, err := os.ReadFile("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -33,3 +26,30 @@ func TestExampleConfigAndSecrets(t *testing.T) {
t.Fatal("extra YAML document accepted") 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")
}
}
+15 -8
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"net" "net"
"os"
"sync" "sync"
"time" "time"
@@ -33,9 +32,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
if err != nil { if err != nil {
return err return err
} }
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) options, err := serverOptions(c)
if err != nil { if err != nil {
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate") return err
} }
client, err := db.ConnectMongo(logger, &c.Database) client, err := db.ConnectMongo(logger, &c.Database)
if err != nil { if err != nil {
@@ -63,11 +62,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
return err return err
} }
defer broker.Close() defer broker.Close()
tokens := map[string]string{} server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
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)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen) listener, err := net.Listen("tcp", c.Listen)
if err != nil { if err != nil {
@@ -103,3 +98,15 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
logger.Info("Notifications service stopped") logger.Info("Notifications service stopped")
return nil 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
}
-4
View File
@@ -1,6 +1,4 @@
listen: "127.0.0.1:8083" listen: "127.0.0.1:8083"
tls_cert: "server.crt"
tls_key: "server.key"
database: database:
driver: mongodb driver: mongodb
settings: settings:
@@ -14,8 +12,6 @@ messaging:
max_reconnects: -1 max_reconnects: -1
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email: email:
driver: resend # sendgrid or resend driver: resend # sendgrid or resend
settings: settings:
+3 -3
View File
@@ -20,14 +20,14 @@ Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this `git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building. checkout, use a local replace to this directory and generate before building.
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service Calls use the trusted private network without caller authentication. The service
maps tokens to configured caller identities. Callers authorize user/destination uses the existing product storage namespace. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API. 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 Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts. original resource, even after completion or expiry. A changed request conflicts.
Keys are scoped to the authenticated caller and retained at least seven days; Keys are scoped to the product namespace and retained at least seven days;
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed 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. 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"; option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
// Internal service: authenticate callers over TLS. Send durably queues work. // Internal service on the trusted private network; no caller authentication. Send durably queues work.
service NotificationsService { service NotificationsService {
rpc Send(SendRequest) returns (SendResponse); rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse); rpc Get(GetRequest) returns (GetResponse);
} }
message SendRequest { message SendRequest {
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days. string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
string user_id = 2; // Caller resolves and authorizes recipient ownership. string user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3; string template_id = 3;
string template_version = 4; string template_version = 4;
+2 -7
View File
@@ -1,7 +1,6 @@
package service_test package service_test
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
@@ -17,10 +16,9 @@ import (
"git.sendico.io/tech/sxchnge/notifications/transport" "git.sendico.io/tech/sxchnge/notifications/transport"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
) )
// Exercise authenticated TLS transport against the real service and storage. // Exercise optional TLS transport against the real service and storage.
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient { func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
t.Helper() t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -42,7 +40,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
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"}))) server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
t.Cleanup(func() { _ = conn.Close() }) t.Cleanup(func() { _ = conn.Close() })
return pb.NewNotificationsServiceClient(conn) 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,8 +26,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type sender struct { type sender struct {
@@ -50,7 +48,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
func TestDurableDelivery(t *testing.T) { func TestDurableDelivery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8.0") container, err := mongodb.Run(ctx, "mongo:latest")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -192,7 +190,7 @@ func TestDurableDelivery(t *testing.T) {
} }
} }
// Verify the real JetStream publisher/consumer path with an isolated broker. // Verify the real JetStream publisher/consumer path with an isolated broker.
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}) 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})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
} }
client := grpcClient(t, svc) client := grpcClient(t, svc)
rpcCtx := callerContext(ctx, "product") rpcCtx := ctx
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"}}}}} 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) sent, err := client.Send(rpcCtx, input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
if err != nil || replay.Notification.Id != sent.Notification.Id { if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err) t.Fatal("RPC idempotency failed", err)
} }
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound { if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
t.Fatal("RPC caller isolation failed", e) t.Fatal("RPC retrieval without credentials failed", e)
} }
if err = broker.PublishDue(ctx); err != nil { if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
+2 -2
View File
@@ -9,11 +9,11 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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/template"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging" mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,7 +21,7 @@ type Store struct {
template.DBImp[*model.Notification] template.DBImp[*model.Notification]
} }
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) { func New(logger mlogger.Logger, db source.Source) (*Store, error) {
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)} s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
ttl := int32(0) ttl := int32(0)
for _, index := range []*ri.Definition{ for _, index := range []*ri.Definition{
+65 -1
View File
@@ -3,7 +3,11 @@
"id": "exchange.completed", "id": "exchange.completed",
"version": "1", "version": "1",
"default_locale": "en", "default_locale": "en",
"parameters": ["first_name", "amount", "currency"], "parameters": [
"first_name",
"amount",
"currency"
],
"locales": { "locales": {
"en": { "en": {
"subject": "Your exchange is complete", "subject": "Your exchange is complete",
@@ -11,5 +15,65 @@
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete." "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>"
}
}
} }
] ]
+3 -35
View File
@@ -2,21 +2,15 @@ package transport
import ( import (
"context" "context"
"crypto/sha256"
"crypto/subtle"
"errors" "errors"
"strings"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
type callerKey struct{}
type API interface { type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error) Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error) Get(context.Context, string, string) (*model.Notification, error)
@@ -26,14 +20,14 @@ type Server struct {
service API service API
} }
// The private endpoint uses the existing product storage namespace without caller authentication.
func New(service API) *Server { return &Server{service: service} } func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) { func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r) input, err := request(r)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Send(ctx, "product", input)
n, err := s.service.Send(ctx, caller, input)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
if r == nil { if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request") return nil, status.Error(codes.InvalidArgument, "missing request")
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Get(ctx, "product", r.Id)
n, err := s.service.Get(ctx, caller, r.Id)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
return &pb.GetResponse{Notification: response(n)}, nil 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 { func rpcError(err error) error {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return status.Error(codes.Canceled, "notification operation canceled") return status.Error(codes.Canceled, "notification operation canceled")
@@ -15,7 +15,6 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
) )
@@ -31,10 +30,9 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) { func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details") return nil, merrors.NoData("private details")
} }
func TestGRPCAuthenticationAndMapping(t *testing.T) { func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20) listener := bufconn.Listen(1 << 20)
token := strings.Repeat("a", 32) server := grpc.NewServer()
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{})) pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
defer server.Stop() defer server.Stop()
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
defer conn.Close() defer conn.Close()
client := pb.NewNotificationsServiceClient(conn) client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated { ctx := context.Background()
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
result, err := client.Send(ctx, req) result, err := client.Send(ctx, req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+5 -2
View File
@@ -24,8 +24,11 @@ organization, role, policy or seed; the runtime creates its indexes.
The runtime mounts health, login, API login, refresh, rotation and logout routes. The runtime mounts health, login, API login, refresh, rotation and logout routes.
Customer/application-client records must exist for login. The customer service Customer/application-client records must exist for login. The customer service
supports atomic account/activation creation and deletion with session revocation. supports atomic account/activation creation and deletion with session revocation.
Signup/profile HTTP wiring and verification delivery/completion remain follow-up Signup and email verification delivery/completion are available when
work. The frontend still uses dummy services. `api.notifications` is configured. Flutter selects the backend using
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
[auth notification setup and contracts](../../docs/auth-notifications.md).
Profile HTTP wiring remains follow-up work.
Sendico integration is not wired. Its organization context belongs only to the 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; future public HTTP adapter. There is no unused public API snapshot in this module;
+2
View File
@@ -2,10 +2,12 @@ package api
import ( import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config" 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" verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
) )
type Config struct { type Config struct {
Notifications *notifications.Config `yaml:"notifications"`
Mw *mwa.Config `yaml:"middleware"` Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"` AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
Verification *verificationconfig.Config `yaml:"verification"` Verification *verificationconfig.Config `yaml:"verification"`
+49 -1
View File
@@ -7,10 +7,18 @@ import (
mr "git.sendico.io/tech/sxchnge/product/app/metrics" mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/response"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/product/twofactor"
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
@@ -26,6 +34,7 @@ import (
) )
type Middleware struct { type Middleware struct {
notifications *notifications.Client
logger mlogger.Logger logger mlogger.Logger
router *chi.Mux router *chi.Mux
apiEndpoint string apiEndpoint string
@@ -47,6 +56,9 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
func (mw *Middleware) Finish() { func (mw *Middleware) Finish() {
mw.metrics.Finish() mw.metrics.Finish()
if mw.notifications != nil {
_ = mw.notifications.Close()
}
} }
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) { func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
@@ -107,6 +119,7 @@ func CreateMiddleware(
config *middleware.Config, config *middleware.Config,
authStorage *middleware.AuthStorageConfig, authStorage *middleware.AuthStorageConfig,
debug bool, debug bool,
notificationConfig ...*notifications.Config,
) (*Middleware, error) { ) (*Middleware, error) {
if config == nil { if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware") return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
@@ -139,15 +152,50 @@ func CreateMiddleware(
return nil, err 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.epdispatcher = httprouter.NewDispatcher(
p.logger, p.logger,
p.router, p.router,
stores.Accounts, stores.Accounts,
stores.Verification,
stores.RefreshTokens, stores.RefreshTokens,
stores.LoginAttempts, stores.LoginAttempts,
config, config,
loginRateLimits, loginRateLimits,
flows,
) )
if factors != nil {
factorhttp.Install(p.epdispatcher, factors, p.logger)
}
return p, nil return p, nil
} }
+1
View File
@@ -83,6 +83,7 @@ func (a *APIImp) installRuntimeBasics(
a.config.Mw, a.config.Mw,
a.config.AuthStorage, a.config.AuthStorage,
debug, debug,
a.config.Notifications,
); err != nil { ); err != nil {
a.logger.Error("Failed to create middleware", zap.Error(err)) a.logger.Error("Failed to create middleware", zap.Error(err))
return err return err
@@ -2,7 +2,6 @@ package runtime_test
import ( import (
"context" "context"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -14,6 +13,7 @@ import (
"git.sendico.io/tech/sxchnge/product/app/profile" "git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime" sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
@@ -24,7 +24,7 @@ import (
func TestProfileStartsOnEmptyDatabase(t *testing.T) { func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute) ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -48,6 +48,7 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
t.Setenv("SXCHNGE_API_PROTOCOL", "http") t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") 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() root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err) require.NoError(t, err)
@@ -0,0 +1,48 @@
package architecture
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Keep the reusable domain independent of transport and session ownership.
func TestSecondFactorDomainBoundary(t *testing.T) {
err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "http" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, im := range file.Imports {
name, err := strconv.Unquote(im.Path.Value)
if err != nil {
return err
}
for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} {
if strings.Contains(name, blocked) {
t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path)
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+182
View File
@@ -0,0 +1,182 @@
// 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
}
+73
View File
@@ -0,0 +1,73 @@
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 -5
View File
@@ -3,19 +3,17 @@ package router
import ( import (
"os" "os"
"github.com/go-chi/chi/v5"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/verification/store"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
) )
type Dispatcher struct { type Dispatcher struct {
@@ -40,11 +38,11 @@ func NewDispatcher(
logger mlogger.Logger, logger mlogger.Logger,
router chi.Router, router chi.Router,
db account.DB, db account.DB,
vdb verification.DB,
rtdb refreshtokens.DB, rtdb refreshtokens.DB,
attempts ratelimits.Store, attempts ratelimits.Store,
config *middleware.Config, config *middleware.Config,
loginRateLimits middleware.LoginRateLimits, loginRateLimits middleware.LoginRateLimits,
flows ...rpublic.Flows,
) *Dispatcher { ) *Dispatcher {
d := &Dispatcher{ d := &Dispatcher{
logger: logger.Named("api_dispatcher"), logger: logger.Named("api_dispatcher"),
@@ -54,7 +52,7 @@ func NewDispatcher(
endpoint := os.Getenv(config.EndPointEnv) endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config) signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) { router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits) d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
}) })
router.Group(func(r chi.Router) { router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature) d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
+6 -3
View File
@@ -5,8 +5,6 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http" "net/http"
"strings" "strings"
@@ -14,6 +12,8 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/ipguard" "git.sendico.io/tech/sxchnge/product/auth/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/response" "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/errors"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging" "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) return nil, response.BadPayload(logger, mservice.Accounts, err)
} }
req.Login = strings.TrimSpace(req.Login) req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password) // Preserve passwords exactly as entered.
req.ClientID = strings.TrimSpace(req.ClientID) req.ClientID = strings.TrimSpace(req.ClientID)
req.DeviceID = strings.TrimSpace(req.DeviceID) req.DeviceID = strings.TrimSpace(req.DeviceID)
req.ClientSecret = strings.TrimSpace(req.ClientSecret) req.ClientSecret = strings.TrimSpace(req.ClientSecret)
@@ -225,6 +225,9 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
if h = a.validateClientAccountBinding(client, account); h != nil { if h = a.validateClientAccountBinding(client, account); h != nil {
return h return h
} }
if a.factors != nil {
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
}
return a.respondPendingLogin(account) return a.respondPendingLogin(account)
} }
@@ -187,7 +187,6 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
zap.NewNop(), zap.NewNop(),
"/", "/",
&testAccountDB{result: account}, &testAccountDB{result: account},
nil,
rtdb, rtdb,
nil, nil,
chi.NewRouter(), chi.NewRouter(),
@@ -0,0 +1,35 @@
package public
import (
"net/http"
"time"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
return func(r *http.Request) http.HandlerFunc {
start := time.Now()
response := handler(r)
return func(w http.ResponseWriter, r *http.Request) {
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
response(wrapped, r)
outcome := "accepted"
if wrapped.Status() >= 500 {
outcome = "unavailable"
} else if wrapped.Status() == 429 {
outcome = "limited"
} else if wrapped.Status() >= 400 {
outcome = "rejected"
}
flowRequests.WithLabelValues(operation, outcome).Inc()
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
}
}
}
@@ -51,7 +51,6 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
zap.NewNop(), zap.NewNop(),
"/api/v1", "/api/v1",
&testAccountDB{result: account}, &testAccountDB{result: account},
nil,
rtdb, rtdb,
nil, nil,
chi.NewRouter(), chi.NewRouter(),
+11 -3
View File
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
session *sessionmodel.SessionIdentifier, session *sessionmodel.SessionIdentifier,
account *customermodel.Account, account *customermodel.Account,
) http.HandlerFunc { ) http.HandlerFunc {
result, err := pr.prepareLogin(ctx, r, session, account)
if err != nil {
return response.Internal(pr.logger, pr.service, err)
}
return result
}
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
refreshToken, err := rtokens.PrepareRefreshToken( refreshToken, err := rtokens.PrepareRefreshToken(
ctx, ctx,
r, r,
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
if err != nil { if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account), pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err) return nil, err
} }
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier) accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
if err != nil { if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account), pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err) return nil, err
} }
token := sresponse.TokenData{ token := sresponse.TokenData{
Token: refreshToken.RefreshToken, Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt, Expiration: refreshToken.ExpiresAt,
} }
return sresponse.Login(pr.logger, account, &accessToken, &token) return sresponse.Login(pr.logger, account, &accessToken, &token), nil
} }
+24 -3
View File
@@ -4,11 +4,12 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/verification/store" "git.sendico.io/tech/sxchnge/product/twofactor"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -16,7 +17,14 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Flows struct {
Onboarding *flow.Service
Factors twofactor.API
}
type PublicRouter struct { type PublicRouter struct {
factors twofactor.API
flow *flow.Service
logger mlogger.Logger logger mlogger.Logger
db account.DB db account.DB
imp *re.HttpEndpointRouter imp *re.HttpEndpointRouter
@@ -37,13 +45,13 @@ func NewRouter(
logger mlogger.Logger, logger mlogger.Logger,
apiEndpoint string, apiEndpoint string,
db account.DB, db account.DB,
vdb verification.DB,
rtdb refreshtokens.DB, rtdb refreshtokens.DB,
attempts ratelimits.Store, attempts ratelimits.Store,
router chi.Router, router chi.Router,
config *middleware.TokenConfig, config *middleware.TokenConfig,
signature *middleware.Signature, signature *middleware.Signature,
limits middleware.LoginRateLimits, limits middleware.LoginRateLimits,
flows ...Flows,
) *PublicRouter { ) *PublicRouter {
l := logger.Named("public") l := logger.Named("public")
hr := PublicRouter{ hr := PublicRouter{
@@ -59,7 +67,20 @@ func NewRouter(
now: time.Now, now: time.Now,
} }
hr.InstallHandler(hr.service, "/login", api.Post, hr.login) 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", api.Post, hr.apiLogin) hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken) hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken) hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
@@ -0,0 +1,259 @@
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
}
@@ -0,0 +1,136 @@
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)
}
+3
View File
@@ -31,3 +31,6 @@ api:
token_length: 32 token_length: 32
check: check:
min_length: 12 min_length: 12
notifications:
address: localhost:8083
activation_url: http://localhost:5126/verify-email
@@ -2,6 +2,10 @@ package accountserviceimp
import ( import (
"context" "context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model" customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
@@ -15,9 +19,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap" "go.uber.org/zap"
"net/url"
"testing"
"time"
) )
func customer(login string) *customermodel.Account { func customer(login string) *customermodel.Account {
@@ -29,7 +30,7 @@ func customer(login string) *customermodel.Account {
func TestCustomerLifecycleWithoutTenancy(t *testing.T) { func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -86,7 +87,10 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
deps, err := edgemongo.ResolveDependencies(factory) deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err) require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{}) raw, rawContext, release, err := deps.Database.Acquire(ctx)
require.NoError(t, err)
defer release()
collections, err := raw.ListCollectionNames(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} { for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden) require.NotContains(t, collections, forbidden)
@@ -116,7 +120,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
require.ErrorIs(t, err, outerFailure) require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test") _, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData) require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{}) count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 2, count) require.EqualValues(t, 2, count)
} }
+16
View File
@@ -0,0 +1,16 @@
package model
import (
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"net/mail"
"strings"
)
func NormalizeEmail(email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
a, err := mail.ParseAddress(email)
if err != nil || a.Address != email || len(email) > 254 {
return "", merrors.InvalidArgument("valid email required")
}
return email, nil
}
+4 -4
View File
@@ -1,14 +1,14 @@
package accountdb package accountdb
import ( import (
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"strings" "strings"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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/template"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -16,11 +16,11 @@ type AccountDB struct {
template.DBImp[*customermodel.Account] template.DBImp[*customermodel.Account]
} }
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) { func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
return CreateWithCollection(logger, db, mservice.Accounts) return CreateWithCollection(logger, db, mservice.Accounts)
} }
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) { func CreateWithCollection(logger mlogger.Logger, db source.Source, collection string) (*AccountDB, error) {
if normalized := strings.TrimSpace(collection); normalized != "" { if normalized := strings.TrimSpace(collection); normalized != "" {
collection = normalized collection = normalized
} else { } else {
+8 -2
View File
@@ -69,13 +69,17 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // 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/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
) )
replace git.sendico.io/tech/sxchnge/shared => ../shared
require ( require (
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
git.sendico.io/tech/sxchnge/shared 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/chi/v5 v5.3.2
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
@@ -89,8 +93,10 @@ require (
go.mongodb.org/mongo-driver/v2 v2.9.0 go.mongodb.org/mongo-driver/v2 v2.9.0
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
golang.org/x/crypto v0.56.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 gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3 moul.io/chizap v1.0.3
) )
replace git.sendico.io/tech/sxchnge/shared => ../shared replace git.sendico.io/tech/sxchnge/notifications/interface => ../notifications/interface
+12 -2
View File
@@ -63,6 +63,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/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 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -226,6 +228,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-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-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.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-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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -267,8 +271,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+88
View File
@@ -0,0 +1,88 @@
// Package notifications adapts the internal notification wire contract to product models.
package notifications
import (
"context"
"crypto/tls"
"crypto/x509"
"os"
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/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}}}}}
}
@@ -0,0 +1,103 @@
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)
}
+5 -5
View File
@@ -1,15 +1,15 @@
package refreshtokens package refreshtokens
import ( import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"strings" "strings"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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/template"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -24,7 +24,7 @@ type RefreshTokenDB struct {
collection Collections collection Collections
} }
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) { func Create(logger mlogger.Logger, db source.Source) (*RefreshTokenDB, error) {
return CreateWithCollections(logger, db, Collections{}) return CreateWithCollections(logger, db, Collections{})
} }
@@ -38,11 +38,11 @@ func (c Collections) WithDefaults() Collections {
return c return c
} }
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) { func CreateWithCollections(logger mlogger.Logger, db source.Source, collections Collections) (*RefreshTokenDB, error) {
collections = collections.WithDefaults() collections = collections.WithDefaults()
p := &RefreshTokenDB{ p := &RefreshTokenDB{
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db), DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
clients: repository.CreateMongoRepository(db, collections.Clients), clients: repository.CreateRepository(db, collections.Clients),
collection: collections, collection: collections,
} }
@@ -8,11 +8,12 @@ import (
"crypto/sha1" "crypto/sha1"
"errors" "errors"
"fmt" "fmt"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing" "testing"
"time" "time"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
factory "git.sendico.io/tech/sxchnge/shared/logging/factory" factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -55,7 +56,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.D
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name())))) database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
logger := factory.NewLogger(true) logger := factory.NewLogger(true)
db, err := refreshtokens.Create(logger, database) db, err := refreshtokens.Create(logger, source.Fixed(database))
require.NoError(t, err, "failed to create refresh tokens db") require.NoError(t, err, "failed to create refresh tokens db")
cleanup := func() { cleanup := func() {
+138
View File
@@ -0,0 +1,138 @@
package twofactor
import (
"context"
"errors"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"strings"
"time"
)
func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) {
if !validScope(req.Scope) {
return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.begin(ctx, req, nil)
}
func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) {
if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) {
return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required")
}
a, p, version, err := s.profile(ctx, req.Subject)
if err != nil {
return model.Challenge{}, err
}
factor, err := s.preferred(p)
if err != nil {
return model.Challenge{}, err
}
if enrollment != nil {
factor = *enrollment
}
// Repeating an operation key resolves to the same immutable challenge snapshot.
id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24])
expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond)
if !req.Deadline.IsZero() && req.Deadline.Before(expires) {
expires = req.Deadline.UTC().Truncate(time.Millisecond)
}
if !expires.After(time.Now()) {
return model.Challenge{}, verification.ErorrTokenExpired()
}
locale := "en"
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
locale = "ru"
}
candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires}
candidate.ID = id
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
r, err := s.store.Challenge(tx, id)
if errors.Is(err, merrors.ErrNoData) {
r = candidate
if err = s.store.CreateChallenge(tx, r); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version {
return nil, merrors.DataConflict("second-factor request changed")
}
return s.issue(tx, r, req.IdempotencyKey, req.IP)
})
if err != nil {
return model.Challenge{}, err
}
return s.deliver(ctx, result)
}
func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) {
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
r, err := s.record(tx, id, req.Subject, req.Scope)
if err != nil {
return nil, err
}
return s.issue(tx, r, req.IdempotencyKey, req.IP)
})
if err != nil {
return model.Challenge{}, err
}
return s.deliver(ctx, result)
}
type issued struct {
record *model.Record
delivery model.Delivery
resendAt time.Time
}
func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) {
if r.VerifiedAt != nil {
return issued{}, verification.ErorrTokenAlreadyUsed()
}
if !r.ExpiresAt.After(time.Now()) {
return issued{}, verification.ErorrTokenExpired()
}
if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) {
return issued{}, merrors.InvalidArgument("idempotency key required")
}
if ip == "" {
ip = "unresolved"
}
secretKey := s.key("code", r.ID.Hex(), key)
now := time.Now().UTC()
req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID).
WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey).
WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now))
token, err := s.tokens.CreateWithResult(ctx, req)
if err != nil {
return issued{}, err
}
if !token.ExpiresAt.After(now) {
return issued{}, verification.ErorrTokenExpired()
}
return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil
}
func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) {
issued, ok := result.(issued)
if !ok {
return model.Challenge{}, merrors.Internal("invalid second-factor issuance result")
}
driver := s.drivers[issued.record.Factor.Channel]
if driver == nil {
return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable")
}
if err := driver.Send(ctx, issued.delivery); err != nil {
s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel())))
return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable")
}
s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel())))
return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil
}
func preferenceScope(factorID string) model.Scope {
return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID}
}
@@ -0,0 +1,23 @@
package delivery
import (
"context"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/notifications"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/logging/mask"
)
type Email struct{ Sender notifications.Sender }
func (Email) Channel() model.Channel { return model.Email }
func (Email) Normalize(address string) (string, error) { return customer.NormalizeEmail(address) }
func (Email) Mask(address string) string { return mask.Email(address) }
func (d Email) Send(ctx context.Context, m model.Delivery) error {
template := "twofactor.code"
if m.Scope.Purpose == vmodel.PurposeLogin {
template = "auth.login"
}
return d.Sender.Send(ctx, notifications.Message{Key: m.Key, UserID: m.Subject.Hex(), Email: m.Factor.Address, Locale: m.Locale, Template: template, Parameters: map[string]string{"code": m.Code}, ExpiresAt: m.ExpiresAt})
}
+188
View File
@@ -0,0 +1,188 @@
// Package factorhttp exposes authenticated factor settings. Operation scope and
// account ownership are resolved on the server; raw proofs never cross HTTP.
package factorhttp
import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *twofactor.Service
logger mlogger.Logger
}
func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) {
h := &router{service: service, logger: logger.Named("twofactor_http")}
reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings)
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll)
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment)
reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference)
reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference)
reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend)
}
func decode(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if err := d.Decode(dst); err != nil {
return merrors.InvalidArgument("invalid request")
}
var extra any
if err := d.Decode(&extra); err != io.EOF {
return merrors.InvalidArgument("invalid request")
}
return nil
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (h *router) result(data any, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err == nil {
response.Accepted(h.logger, data).ServeHTTP(w, r)
return
}
var handler http.HandlerFunc
if throttle, ok := verification.Throttle(err); ok {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds())))))
handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code")
} else {
switch {
case errors.Is(err, verification.ErrTokenExpired):
handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation")
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation")
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData):
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used")
case errors.Is(err, merrors.ErrInvalidArg):
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields")
case errors.Is(err, merrors.ErrUnauthorized):
handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation")
case errors.Is(err, merrors.ErrDataConflict):
handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists")
case errors.Is(err, merrors.ErrNotImplemented):
handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable")
default:
h.logger.Warn("Second-factor settings dependency failed")
handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry")
}
}
handler.ServeHTTP(w, r)
}
}
type challengeDTO struct {
ID string `json:"challengeId"`
Channel model.Channel `json:"channel"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func mapChallenge(c model.Challenge) challengeDTO {
return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt}
}
type choiceDTO struct {
ID string `json:"id"`
Channel model.Channel `json:"channel"`
Target string `json:"target"`
}
type settingsDTO struct {
Required bool `json:"required"`
Preferred string `json:"preferred"`
Factors []choiceDTO `json:"factors"`
}
func mapSettings(s model.Settings) settingsDTO {
result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}}
for _, f := range s.Factors {
result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target})
}
return result
}
func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
s, err := h.service.Settings(r.Context(), a.ID)
return h.result(mapSettings(s), err)
}
func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req struct {
Channel model.Channel `json:"channel"`
Address string `json:"address"`
Key string `json:"idempotencyKey"`
}
if err := decode(r, &req); err != nil {
return h.result(nil, err)
}
c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r))
return h.result(mapChallenge(c), err)
}
func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req struct {
ID string `json:"challengeId"`
Code string `json:"code"`
}
if err := decode(r, &req); err != nil {
return h.result(nil, err)
}
s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code)
return h.result(mapSettings(s), err)
}
func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req struct {
ID string `json:"factorId"`
Key string `json:"idempotencyKey"`
}
if err := decode(r, &req); err != nil {
return h.result(nil, err)
}
c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
return h.result(mapChallenge(c), err)
}
func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req struct {
Factor string `json:"factorId"`
ID string `json:"challengeId"`
Code string `json:"code"`
}
if err := decode(r, &req); err != nil {
return h.result(nil, err)
}
s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code)
return h.result(mapSettings(s), err)
}
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req struct {
ID string `json:"challengeId"`
Key string `json:"idempotencyKey"`
}
if err := decode(r, &req); err != nil {
return h.result(nil, err)
}
c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
return h.result(mapChallenge(c), err)
}
@@ -0,0 +1,45 @@
package factorhttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
api "git.sendico.io/tech/sxchnge/shared/http"
mservice "git.sendico.io/tech/sxchnge/shared/service"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
type accountRoutes map[string]sresponse.AccountHandlerFunc
func (r accountRoutes) AccountHandler(_ mservice.Type, path string, _ api.HTTPMethod, h sresponse.AccountHandlerFunc) {
r[path] = h
}
func TestSettingsTransportRejectsCallerOwnedScopeAndDestinationOverrides(t *testing.T) {
routes := accountRoutes{}
Install(routes, nil, zap.NewNop())
// Every route is registered through the authenticated-account contract.
require.Len(t, routes, 6)
for _, tc := range []struct{ path, body string }{
{"/two-factor/enroll", `{"channel":"email","address":"a@example.com","subject":"other-account"}`},
{"/two-factor/enroll/verify", `{"challengeId":"id","code":"123456","purpose":"login"}`},
{"/two-factor/preference", `{"factorId":"id","address":"arbitrary@example.com"}`},
{"/two-factor/preference/verify", `{"factorId":"id","code":"123456","verified":true}`},
{"/two-factor/resend", `{"challengeId":"id","target":"arbitrary@example.com"}`},
{"/two-factor/enroll", `{"channel":"email"} {"channel":"sms"}`},
} {
t.Run(tc.path+tc.body, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
w := httptest.NewRecorder()
routes[tc.path](req, &customer.Account{}, nil).ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
require.NotContains(t, w.Body.String(), "arbitrary@example.com")
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package model
import (
verification "git.sendico.io/tech/sxchnge/product/verification/model"
storable "git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
"time"
)
type Channel string
const Email Channel = "email"
// Scope is supplied by the owning operation, never accepted as an authorization
// decision from a caller's HTTP body. ContextRef identifies the exact operation.
type Scope struct {
Purpose verification.VerificationPurpose `bson:"purpose"`
ContextRef string `bson:"contextRef"`
}
type Request struct {
Subject bson.ObjectID
Scope Scope
Deadline time.Time
IdempotencyKey string
IP string
}
type Factor struct {
ID string `bson:"id"`
Channel Channel `bson:"channel"`
Address string `bson:"address"`
Verified bool `bson:"verified"`
}
type Profile struct {
storable.Base `bson:",inline"`
Preferred string `bson:"preferred"`
Factors []Factor `bson:"factors"`
Revision string `bson:"revision"`
Fence string `bson:"fence"`
}
func (*Profile) Collection() string { return "two_factor_profiles" }
type Record struct {
storable.Base `bson:",inline"`
Subject bson.ObjectID `bson:"subject"`
Scope Scope `bson:"scope"`
Factor Factor `bson:"factor"`
ProfileVersion string `bson:"profileVersion"`
Locale string `bson:"locale"`
ExpiresAt time.Time `bson:"expiresAt"`
VerifiedAt *time.Time `bson:"verifiedAt,omitempty"`
}
func (*Record) Collection() string { return "two_factor_challenges" }
type Challenge struct {
ID string
Channel Channel
Target string
ExpiresAt, ResendAt time.Time
}
type Proof struct {
Token string
ExpiresAt time.Time
}
type Delivery struct {
Key string
Subject bson.ObjectID
Scope Scope
Factor Factor
Locale, Code string
ExpiresAt time.Time
}
type Choice struct {
ID string
Channel Channel
Target string
}
type Settings struct {
Required bool
Preferred string
Factors []Choice
}
+156
View File
@@ -0,0 +1,156 @@
package twofactor
import (
"context"
"crypto/rand"
"errors"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"time"
)
func isRejection(err error) bool {
return errors.Is(err, verification.ErrTokenNotFound) || errors.Is(err, verification.ErrTokenAlreadyUsed) || errors.Is(err, verification.ErrTokenExpired) || errors.Is(err, verification.ErrTokenAttemptsExceeded)
}
func (s *Service) saveProfile(ctx context.Context, p *model.Profile) error {
_, err := s.store.Profile(ctx, p.ID)
if errors.Is(err, merrors.ErrNoData) {
return s.store.CreateProfile(ctx, p)
}
if err != nil {
return err
}
return s.store.UpdateProfile(ctx, p)
}
func (s *Service) Settings(ctx context.Context, subject bson.ObjectID) (model.Settings, error) {
_, p, _, err := s.profile(ctx, subject)
if err != nil {
return model.Settings{}, err
}
result := model.Settings{Required: true, Preferred: p.Preferred}
for _, f := range p.Factors {
if f.Verified {
if driver := s.drivers[f.Channel]; driver != nil {
result.Factors = append(result.Factors, model.Choice{ID: f.ID, Channel: f.Channel, Target: driver.Mask(f.Address)})
}
}
}
return result, nil
}
func (s *Service) Enroll(ctx context.Context, subject bson.ObjectID, channel model.Channel, address, key, ip string) (model.Challenge, error) {
driver := s.drivers[channel]
if driver == nil {
return model.Challenge{}, merrors.NotImplemented("second-factor channel is not supported")
}
normalized, err := driver.Normalize(address)
if err != nil {
return model.Challenge{}, err
}
_, p, _, err := s.profile(ctx, subject)
if err != nil {
return model.Challenge{}, err
}
if len(p.Factors) >= 5 {
return model.Challenge{}, merrors.InvalidArgument("maximum enrolled factors reached")
}
for _, f := range p.Factors {
if f.Channel == channel && f.Address == normalized {
return model.Challenge{}, merrors.DataConflict("factor already enrolled")
}
}
f := model.Factor{ID: s.key("factor", subject.Hex(), string(channel), normalized)[:24], Channel: channel, Address: normalized}
scope := model.Scope{Purpose: vmodel.PurposeFactorEnrollment, ContextRef: "enroll:" + f.ID}
return s.begin(ctx, model.Request{Subject: subject, Scope: scope, IdempotencyKey: key, IP: ip}, &f)
}
func (s *Service) ConfirmEnrollment(ctx context.Context, subject bson.ObjectID, id, code string) (model.Settings, error) {
cid, err := bson.ObjectIDFromHex(id)
if err != nil {
return model.Settings{}, verification.ErorrTokenNotFound()
}
r, err := s.store.Challenge(ctx, cid)
if err != nil {
return model.Settings{}, err
}
if r.Subject != subject || r.Scope.Purpose != vmodel.PurposeFactorEnrollment {
return model.Settings{}, verification.ErorrTokenNotFound()
}
err = s.complete(ctx, subject, r.Scope, id, code, func(tx context.Context) error {
_, p, _, err := s.profile(tx, subject)
if err != nil {
return err
}
if len(p.Factors) >= 5 {
return merrors.InvalidArgument("maximum enrolled factors reached")
}
for _, f := range p.Factors {
if f.ID == r.Factor.ID {
return merrors.DataConflict("factor already enrolled")
}
}
factor := r.Factor
factor.Verified = true
p.Factors = append(p.Factors, factor)
p.Revision = rand.Text()
return s.saveProfile(tx, p)
})
if err != nil {
return model.Settings{}, err
}
return s.Settings(ctx, subject)
}
func (s *Service) BeginPreference(ctx context.Context, subject bson.ObjectID, factorID, key, ip string) (model.Challenge, error) {
_, p, _, err := s.profile(ctx, subject)
if err != nil {
return model.Challenge{}, err
}
if !s.available(p, factorID) {
return model.Challenge{}, merrors.InvalidArgument("verified supported factor required")
}
return s.Begin(ctx, model.Request{Subject: subject, Scope: preferenceScope(factorID), IdempotencyKey: key, IP: ip, Deadline: time.Now().Add(TTL)})
}
func (s *Service) ConfirmPreference(ctx context.Context, subject bson.ObjectID, factorID, id, code string) (model.Settings, error) {
scope := preferenceScope(factorID)
err := s.Complete(ctx, subject, scope, id, code, func(tx context.Context) error {
_, p, _, err := s.profile(tx, subject)
if err != nil {
return err
}
if !s.available(p, factorID) {
return merrors.InvalidArgument("verified supported factor required")
}
p.Preferred = factorID
p.Revision = rand.Text()
return s.saveProfile(tx, p)
})
if err != nil {
return model.Settings{}, err
}
return s.Settings(ctx, subject)
}
func (s *Service) available(p *model.Profile, id string) bool {
for _, f := range p.Factors {
if f.ID == id && f.Verified && s.drivers[f.Channel] != nil {
return true
}
}
return false
}
// ResendSettings cannot be used to send or authorize a pending login challenge.
func (s *Service) ResendSettings(ctx context.Context, subject bson.ObjectID, id, key, ip string) (model.Challenge, error) {
cid, err := bson.ObjectIDFromHex(id)
if err != nil {
return model.Challenge{}, verification.ErorrTokenNotFound()
}
r, err := s.store.Challenge(ctx, cid)
if err != nil {
return model.Challenge{}, err
}
if r.Subject != subject || (r.Scope.Purpose != vmodel.PurposeFactorEnrollment && r.Scope.Purpose != vmodel.PurposeFactorPreference) {
return model.Challenge{}, verification.ErorrTokenNotFound()
}
return s.Resend(ctx, model.Request{Subject: subject, Scope: r.Scope, IdempotencyKey: key, IP: ip}, id)
}
+153
View File
@@ -0,0 +1,153 @@
package twofactor
import (
"context"
"crypto/rand"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"strings"
"time"
)
type verificationOutcome struct {
proof model.Proof
rejected error
}
func (s *Service) Verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
if !validScope(scope) {
return model.Proof{}, merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.verify(ctx, subject, scope, id, code)
}
func (s *Service) verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
code = strings.TrimSpace(code)
if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
return model.Proof{}, verification.ErorrTokenNotFound()
}
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
r, err := s.record(tx, id, subject, scope)
if err != nil {
return nil, err
}
if r.VerifiedAt != nil {
return nil, verification.ErorrTokenAlreadyUsed()
}
// Commit rejected verification attempts while rolling back infrastructure
// failures. Sendico's Consume records misses in the enclosing transaction.
_, err = s.tokens.Consume(tx, subject, scope.Purpose, r.ID.Hex(), code)
if err != nil {
if isRejection(err) {
return verificationOutcome{rejected: err}, nil
}
return nil, err
}
ttl := min(proofTTL, time.Until(r.ExpiresAt))
if ttl <= 0 {
return nil, verification.ErorrTokenExpired()
}
token, err := s.tokens.CreateWithResult(tx, verification.NewLinkRequest(subject, vmodel.PurposeFactorProof, r.ID.Hex()).WithContextRef(scopeKey(scope)).WithTTL(ttl).WithIdempotencyKey(s.key("proof", r.ID.Hex())))
if err != nil {
return nil, err
}
now := time.Now().UTC()
r.VerifiedAt = &now
if err = s.store.UpdateChallenge(tx, r); err != nil {
return nil, err
}
return verificationOutcome{proof: model.Proof{Token: token.VerificationToken, ExpiresAt: token.ExpiresAt}}, nil
})
if err != nil {
return model.Proof{}, err
}
outcome, ok := result.(verificationOutcome)
if !ok {
return model.Proof{}, merrors.Internal("invalid second-factor verification result")
}
if outcome.rejected != nil {
return model.Proof{}, outcome.rejected
}
s.logger.Debug("Second-factor proof prepared", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
return outcome.proof, nil
}
func (s *Service) Consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
if !validScope(scope) {
return merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.consume(ctx, subject, scope, proof, action)
}
func (s *Service) consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
if action == nil || len(proof.Token) != 43 {
return merrors.InvalidArgument("proof and transactional action required")
}
_, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.tokens.Consume(tx, subject, vmodel.PurposeFactorProof, scopeKey(scope), proof.Token)
if err != nil {
return nil, err
}
r, err := s.record(tx, token.Target, subject, scope)
if err != nil {
return nil, err
}
if r.VerifiedAt == nil {
return nil, verification.ErorrTokenNotFound()
}
// Touch the profile in this transaction so a concurrent preference update
// conflicts and cannot let an old-factor proof authorize a later action.
_, p, _, err := s.profile(tx, subject)
if err != nil {
return nil, err
}
p.Fence = rand.Text()
if err = s.saveProfile(tx, p); err != nil {
return nil, err
}
return nil, action(tx)
})
if err == nil {
s.logger.Debug("Second-factor proof consumption prepared", zap.String("purpose", string(scope.Purpose)))
}
return err
}
// Complete atomically verifies the code, consumes its proof and commits the
// owning operation. A failed operation leaves the code retryable; a rejected
// code still commits its attempt counter.
func (s *Service) Complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
if !validScope(scope) {
return merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.complete(ctx, subject, scope, id, code, action)
}
func (s *Service) complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
proof, err := s.verify(tx, subject, scope, id, code)
if err != nil {
if isRejection(err) {
return verificationOutcome{rejected: err}, nil
}
return nil, err
}
if err = s.consume(tx, subject, scope, proof, action); err != nil {
return nil, err
}
return verificationOutcome{}, nil
})
if err != nil {
return err
}
outcome, ok := result.(verificationOutcome)
if !ok {
return merrors.Internal("invalid second-factor completion result")
}
if outcome.rejected == nil {
s.logger.Info("Second-factor operation completed", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
} else {
s.logger.Info("Second-factor code rejected", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
}
return outcome.rejected
}
+159
View File
@@ -0,0 +1,159 @@
// Package twofactor owns second-factor policy, challenges and one-use proofs.
// It deliberately has no dependency on auth HTTP, JWTs or session creation.
package twofactor
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
account "git.sendico.io/tech/sxchnge/product/customers/store"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
)
const TTL = 10 * time.Minute
const Cooldown = time.Minute
const proofTTL = 2 * time.Minute
const primary = "primary-email"
// API is consumed by auth and other operation owners. Complete joins code
// verification, proof consumption and the owner's local database action.
type API interface {
Begin(context.Context, model.Request) (model.Challenge, error)
Resend(context.Context, model.Request, string) (model.Challenge, error)
Complete(context.Context, bson.ObjectID, model.Scope, string, string, func(context.Context) error) error
}
type Store interface {
Profile(context.Context, bson.ObjectID) (*model.Profile, error)
CreateProfile(context.Context, *model.Profile) error
UpdateProfile(context.Context, *model.Profile) error
Challenge(context.Context, bson.ObjectID) (*model.Record, error)
CreateChallenge(context.Context, *model.Record) error
UpdateChallenge(context.Context, *model.Record) error
}
type Driver interface {
Channel() model.Channel
Normalize(string) (string, error)
Mask(string) string
Send(context.Context, model.Delivery) error
}
type Service struct {
accounts account.DB
tokens verification.DB
store Store
tx transaction.Factory
secret []byte
drivers map[model.Channel]Driver
logger mlogger.Logger
}
func New(accounts account.DB, tokens verification.DB, store Store, tx transaction.Factory, secret []byte, logger mlogger.Logger, drivers ...Driver) (*Service, error) {
if accounts == nil || tokens == nil || store == nil || tx == nil || len(secret) < 32 || logger == nil {
return nil, merrors.InvalidArgument("2FA dependencies and private key material are required")
}
s := &Service{accounts: accounts, tokens: tokens, store: store, tx: tx, secret: append([]byte(nil), secret...), drivers: map[model.Channel]Driver{}, logger: logger.Named("twofactor")}
for _, d := range drivers {
if d == nil || s.drivers[d.Channel()] != nil {
return nil, merrors.InvalidArgument("unique second-factor channel drivers required")
}
s.drivers[d.Channel()] = d
}
if s.drivers[model.Email] == nil {
return nil, merrors.InvalidArgument("verified account email channel is required")
}
return s, nil
}
func (s *Service) key(parts ...string) string {
h := hmac.New(sha256.New, s.secret)
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func validScope(scope model.Scope) bool {
if strings.TrimSpace(scope.ContextRef) == "" || len(scope.ContextRef) > 256 || strings.ContainsRune(scope.ContextRef, 0) {
return false
}
switch scope.Purpose {
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
return true
}
return false
}
func scopeKey(scope model.Scope) string { return string(scope.Purpose) + ":" + scope.ContextRef }
func (s *Service) profile(ctx context.Context, id bson.ObjectID) (*customer.Account, *model.Profile, string, error) {
a := &customer.Account{}
if err := s.accounts.Get(ctx, id, a); err != nil {
return nil, nil, "", err
}
if !a.IsActive() || a.IsArchived() {
return nil, nil, "", merrors.Unauthorized("active verified account required")
}
p, err := s.store.Profile(ctx, id)
if errors.Is(err, merrors.ErrNoData) {
p = &model.Profile{Preferred: primary, Revision: "initial"}
p.ID = id
} else if err != nil {
return nil, nil, "", err
}
// The primary address is authorized by account activation, never by request data.
factors := []model.Factor{{ID: primary, Channel: model.Email, Address: a.Login, Verified: true}}
for _, f := range p.Factors {
if f.ID != primary {
factors = append(factors, f)
}
}
p.Factors = factors
version := s.key("profile", id.Hex(), a.Login, p.Revision)
return a, p, version, nil
}
func (s *Service) preferred(p *model.Profile) (model.Factor, error) {
for _, f := range p.Factors {
if f.ID == p.Preferred && f.Verified && s.drivers[f.Channel] != nil {
return f, nil
}
}
return model.Factor{}, merrors.Unauthorized("configured second factor is unavailable")
}
func (s *Service) record(ctx context.Context, idText string, subject bson.ObjectID, scope model.Scope) (*model.Record, error) {
id, err := bson.ObjectIDFromHex(idText)
if err != nil {
return nil, verification.ErorrTokenNotFound()
}
r, err := s.store.Challenge(ctx, id)
if errors.Is(err, merrors.ErrNoData) {
return nil, verification.ErorrTokenNotFound()
}
if err != nil {
return nil, err
}
if r.Subject != subject || r.Scope != scope {
return nil, verification.ErorrTokenNotFound()
}
if !r.ExpiresAt.After(time.Now()) {
return nil, verification.ErorrTokenExpired()
}
_, _, version, err := s.profile(ctx, subject)
if err != nil {
return nil, err
}
if version != r.ProfileVersion {
return nil, merrors.Unauthorized("second-factor settings changed; restart verification")
}
if s.drivers[r.Factor.Channel] == nil {
return nil, merrors.NotImplemented("second-factor channel unavailable")
}
return r, nil
}
+235
View File
@@ -0,0 +1,235 @@
package twofactor_test
import (
"context"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type capturedDriver struct {
deliveries []model.Delivery
fail bool
}
func (*capturedDriver) Channel() model.Channel { return model.Email }
func (*capturedDriver) Normalize(s string) (string, error) { return customer.NormalizeEmail(s) }
func (*capturedDriver) Mask(string) string { return "masked" }
func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error {
d.deliveries = append(d.deliveries, m)
if d.fail {
return merrors.ServiceUnavailable("queue unavailable")
}
return nil
}
func TestServiceTransactionsAndFactors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo: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)
})
}
+53
View File
@@ -0,0 +1,53 @@
package store
import (
"context"
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/template"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store struct {
profiles *template.DBImp[*model.Profile]
challenges *template.DBImp[*model.Record]
}
func Open(logger mlogger.Logger, factory db.Factory) (*Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
s := &Store{profiles: template.Create[*model.Profile](logger, "two_factor_profiles", d.Database), challenges: template.Create[*model.Record](logger, "two_factor_challenges", d.Database)}
retention := int32(86400)
err = s.challenges.Repository.CreateIndex(&ri.Definition{Name: "two_factor_expiry", Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, TTL: &retention})
if err != nil {
return nil, err
}
return s, nil
}
func (s *Store) Profile(ctx context.Context, id bson.ObjectID) (*model.Profile, error) {
p := &model.Profile{}
err := s.profiles.Get(ctx, id, p)
return p, err
}
func (s *Store) CreateProfile(ctx context.Context, p *model.Profile) error {
return s.profiles.Create(ctx, p)
}
func (s *Store) UpdateProfile(ctx context.Context, p *model.Profile) error {
return s.profiles.Update(ctx, p)
}
func (s *Store) Challenge(ctx context.Context, id bson.ObjectID) (*model.Record, error) {
r := &model.Record{}
err := s.challenges.Get(ctx, id, r)
return r, err
}
func (s *Store) CreateChallenge(ctx context.Context, r *model.Record) error {
return s.challenges.Create(ctx, r)
}
func (s *Store) UpdateChallenge(ctx context.Context, r *model.Record) error {
return s.challenges.Update(ctx, r)
}
@@ -17,6 +17,9 @@ const (
PurposePasswordReset VerificationPurpose = "password_reset" PurposePasswordReset VerificationPurpose = "password_reset"
PurposeMagicLogin VerificationPurpose = "magic_login" PurposeMagicLogin VerificationPurpose = "magic_login"
PurposeLogin VerificationPurpose = "login" PurposeLogin VerificationPurpose = "login"
PurposeFactorEnrollment VerificationPurpose = "factor_enrollment"
PurposeFactorPreference VerificationPurpose = "factor_preference"
PurposeFactorProof VerificationPurpose = "factor_proof"
PurposePayout VerificationPurpose = "payout" PurposePayout VerificationPurpose = "payout"
) )
@@ -36,6 +39,12 @@ func VPFromString(s string) (VerificationPurpose, error) {
return PurposeMagicLogin, nil return PurposeMagicLogin, nil
case string(PurposeLogin): case string(PurposeLogin):
return PurposeLogin, nil return PurposeLogin, nil
case string(PurposeFactorEnrollment):
return PurposeFactorEnrollment, nil
case string(PurposeFactorPreference):
return PurposeFactorPreference, nil
case string(PurposeFactorProof):
return PurposeFactorProof, nil
case string(PurposePayout): case string(PurposePayout):
return PurposePayout, nil return PurposePayout, nil
default: default:
@@ -1,24 +1,13 @@
package verification package verification
import ( import (
"strings"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
"go.uber.org/zap" "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 { func tokenIsDigitsOnly(value string) bool {
if value == "" { if value == "" {
return false return false
@@ -38,7 +27,6 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
zap.String("token_purpose", string(token.Purpose)), zap.String("token_purpose", string(token.Purpose)),
zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""), zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""),
zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil), 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_has_salt", token.Salt != nil),
zap.Bool("token_used", token.UsedAt != nil), zap.Bool("token_used", token.UsedAt != nil),
zap.Time("token_expires_at", token.ExpiresAt), zap.Time("token_expires_at", token.ExpiresAt),
@@ -46,18 +34,12 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
zap.Bool("token_has_max_retries", token.MaxRetries != nil), 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 { if token.UsedAt != nil {
fields = append(fields, zap.Time("token_used_at", *token.UsedAt)) fields = append(fields, zap.Time("token_used_at", *token.UsedAt))
} }
if token.MaxRetries != nil { if token.MaxRetries != nil {
fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries)) 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) != "" { if strings.TrimSpace(token.ContextRef) != "" {
fields = append(fields, zap.String("token_context_ref", token.ContextRef)) fields = append(fields, zap.String("token_context_ref", token.ContextRef))
} }
@@ -3,8 +3,8 @@ package verification
import ( import (
"context" "context"
"errors" "errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
mutil "git.sendico.io/tech/sxchnge/shared/database/query" 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"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
@@ -132,8 +132,6 @@ func (db *verificationDB) lookupConsumeOTPToken(
zap.Time("candidate_expires_at", token.ExpiresAt), zap.Time("candidate_expires_at", token.ExpiresAt),
zap.Int("candidate_attempts", token.Attempts), zap.Int("candidate_attempts", token.Attempts),
zap.Bool("candidate_has_max_retries", token.MaxRetries != nil), 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), zap.Bool("candidate_match", match),
) )
if match { if match {
@@ -1,10 +1,10 @@
package verification package verification
import ( import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings" "strings"
"time" "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"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -72,7 +72,6 @@ func (scope consumeScope) startFields() []zap.Field {
zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)), zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)),
zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)), zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)),
zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken), zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken),
zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))),
) )
} }
+16 -4
View File
@@ -3,9 +3,9 @@ package verification
import ( import (
"context" "context"
"errors" "errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"time" "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"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
@@ -21,7 +21,7 @@ func activeContextFilter(request *Request, now time.Time) builder.Query {
filters := []builder.Query{ filters := []builder.Query{
repository.Filter("accountRef", request.AccountRef), repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose), repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target), targetFilter(request.Target),
repository.Filter("usedAt", nil), repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), 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{ filters := []builder.Query{
repository.Filter("accountRef", request.AccountRef), repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose), repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target), targetFilter(request.Target),
repository.Filter("usedAt", nil), repository.Filter("usedAt", nil),
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff), repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
@@ -140,7 +140,10 @@ func (db *verificationDB) CreateWithResult(
// Always store an idempotency key. Client keys enable replay; generated // Always store an idempotency key. Client keys enable replay; generated
// keys avoid null-key collisions on older Mongo indexes and keep // keys avoid null-key collisions on older Mongo indexes and keep
// transaction retries deterministic. // transaction retries deterministic.
idempotencyKey = syntheticIdempotencyKey() idempotencyKey, err = syntheticIdempotencyKey()
if err != nil {
return CreateResult{}, err
}
hasIdempotency = true hasIdempotency = true
} }
@@ -246,3 +249,12 @@ func (db *verificationDB) CreateWithResult(
createResult.VerificationToken = raw createResult.VerificationToken = raw
return createResult, nil 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))
}
+4 -4
View File
@@ -1,16 +1,16 @@
package verification package verification
import ( import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings" "strings"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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/template"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -29,7 +29,7 @@ const verificationContextIdempotencyIndexName = "uniq_verification_context_idemp
func Create( func Create(
logger mlogger.Logger, logger mlogger.Logger,
db *mongo.Database, db source.Source,
tf transaction.Factory, tf transaction.Factory,
) (*verificationDB, error) { ) (*verificationDB, error) {
return CreateWithCollection(logger, db, tf, "") return CreateWithCollection(logger, db, tf, "")
@@ -37,7 +37,7 @@ func Create(
func CreateWithCollection( func CreateWithCollection(
logger mlogger.Logger, logger mlogger.Logger,
db *mongo.Database, db source.Source,
tf transaction.Factory, tf transaction.Factory,
collection string, collection string,
) (*verificationDB, error) { ) (*verificationDB, error) {
@@ -2,15 +2,16 @@ package verification
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"errors" "errors"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings" "strings"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
mutil "git.sendico.io/tech/sxchnge/shared/database/query" 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"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
) )
func normalizedIdempotencyKey(value *string) (string, bool) { func normalizedIdempotencyKey(value *string) (string, bool) {
@@ -24,8 +25,12 @@ func normalizedIdempotencyKey(value *string) (string, bool) {
return key, true return key, true
} }
func syntheticIdempotencyKey() string { func syntheticIdempotencyKey() (string, error) {
return "auto:" + bson.NewObjectID().Hex() b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", merrors.InternalWrap(err, "generate verification key")
}
return "auto:" + base64.RawURLEncoding.EncodeToString(b), nil
} }
func idempotencyContextFilter( func idempotencyContextFilter(
@@ -35,7 +40,7 @@ func idempotencyContextFilter(
return repository.Query().And( return repository.Query().And(
repository.Filter("accountRef", request.AccountRef), repository.Filter("accountRef", request.AccountRef),
repository.Filter("purpose", request.Purpose), repository.Filter("purpose", request.Purpose),
repository.Filter("target", request.Target), targetFilter(request.Target),
repository.Filter("idempotencyKey", idempotencyKey), repository.Filter("idempotencyKey", idempotencyKey),
) )
} }
@@ -75,6 +80,12 @@ func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *
if existingContextRef != request.ContextRef { if existingContextRef != request.ContextRef {
return nil, false, ErrorIdempotencyConflict() 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 { if replayed == nil {
token := tokens[i] token := tokens[i]
replayed = &token replayed = &token
@@ -1,9 +1,9 @@
package verification package verification
import ( import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"time" "time"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -62,6 +62,18 @@ func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool {
func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool { func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool {
switch elem.Key { 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": case "$and":
arr, ok := elem.Value.(bson.A) arr, ok := elem.Value.(bson.A)
if !ok { if !ok {
@@ -3,12 +3,13 @@ package verification
import ( import (
"context" "context"
"errors" "errors"
"testing"
"time"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model" verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"testing"
"time"
) )
func TestCreate_CooldownBlocksCreation(t *testing.T) { func TestCreate_CooldownBlocksCreation(t *testing.T) {
@@ -174,3 +175,16 @@ func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) {
} }
repo.mu.Unlock() 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)
}
+19 -37
View File
@@ -4,56 +4,38 @@ import (
"context" "context"
mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo" mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/logging" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo" mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo/readpref" "go.mongodb.org/mongo-driver/v2/mongo/readpref"
) )
// Connection represents a low-level database connection lifecycle.
type Connection interface { type Connection interface {
Disconnect(ctx context.Context) error Disconnect(context.Context) error
Ping(ctx context.Context) error Ping(context.Context) error
} }
// MongoConnection provides direct access to the underlying mongo client.
type MongoConnection struct { type MongoConnection struct {
client *mongo.Client database source.Source
database string close func(context.Context) error
}
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 { func (c *MongoConnection) Ping(ctx context.Context) error {
return c.client.Ping(ctx, readpref.Primary()) database, ctx, release, err := c.database.Acquire(ctx)
if err != nil {
return err
}
defer release()
return database.Client().Ping(ctx, readpref.Primary())
} }
// ConnectMongo returns a low-level MongoDB connection without constructing repositories.
func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) { func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) {
if config == nil { if config == nil || config.Driver != Mongo {
return nil, merrors.InvalidArgument("database configuration is nil", "config") return nil, merrors.InvalidArgument("MongoDB configuration is required")
} }
if config.Driver != Mongo { database, close, err := mongoimpl.OpenSource(logger, config.Settings)
return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver")
}
client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings)
if err != nil { if err != nil {
return nil, err 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 ( import (
"git.sendico.io/tech/sxchnge/shared/database" "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/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors" "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. // Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
type Dependencies struct { type Dependencies struct {
Database *mongo.Database Database source.Source
Transactions transaction.Factory Transactions transaction.Factory
} }
type DatabaseProvider interface { type DatabaseProvider interface {
MongoDatabase() *mongo.Database MongoDatabase() source.Source
} }
func ResolveDependencies(dbf db.Factory) (*Dependencies, error) { func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
+41 -39
View File
@@ -3,15 +3,18 @@ package mongo
import ( import (
"context" "context"
"fmt" "fmt"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"net" "net"
"os" "os"
"strings" "strings"
"time"
mutil "git.sendico.io/tech/sxchnge/shared/config" mutil "git.sendico.io/tech/sxchnge/shared/config"
"git.sendico.io/tech/sxchnge/shared/database/internal/transaction" "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" "git.sendico.io/tech/sxchnge/shared/database/transaction"
dbvault "git.sendico.io/tech/sxchnge/shared/database/vault"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/readpref" "go.mongodb.org/mongo-driver/v2/mongo/readpref"
@@ -20,6 +23,7 @@ import (
// Config represents configuration // Config represents configuration
type Config struct { type Config struct {
Vault *dbvault.Config `mapstructure:"vault"`
Port *string `mapstructure:"port"` Port *string `mapstructure:"port"`
PortEnv *string `mapstructure:"port_env"` PortEnv *string `mapstructure:"port_env"`
User *string `mapstructure:"user"` User *string `mapstructure:"user"`
@@ -119,37 +123,42 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
return client, nil 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 // DB represents the structure of the database
type DB struct { type DB struct {
logger mlogger.Logger logger mlogger.Logger
config *DBSettings database source.Source
client *mongo.Client close func(context.Context) error
}
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 { func (db *DB) TransactionFactory() transaction.Factory {
return transactionimp.CreateFactory(db.client) 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
} }
func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string { func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string {
@@ -217,24 +226,17 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
} }
func (db *DB) CloseConnection() { func (db *DB) CloseConnection() {
if err := db.client.Disconnect(context.Background()); err != nil { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
db.logger.Warn("Failed to close connection", zap.Error(err)) defer cancel()
if err := db.close(ctx); err != nil {
db.logger.Warn("Failed to drain database 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) { func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) {
client, _, dbSettings, err := ConnectClient(logger, settings) database, close, err := OpenSource(logger, settings)
if err != nil { if err != nil {
return nil, err 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,7 +12,14 @@ import (
) )
func (r *MongoRepository) CreateIndex(def *ri.Definition) error { func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if r.collection == nil { ctx := context.Background()
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set") return merrors.NoData("data collection is not set")
} }
if len(def.Keys) == 0 { if len(def.Keys) == 0 {
@@ -49,28 +56,40 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
opts.SetSparse(def.Sparse) opts.SetSparse(def.Sparse)
} }
_, err := r.collection.Indexes().CreateOne( _, err = collection.Indexes().CreateOne(
context.Background(), ctx,
mongo.IndexModel{Keys: keys, Options: opts}, mongo.IndexModel{Keys: keys, Options: opts},
) )
return err return err
} }
func (r *MongoRepository) DropIndex(ctx context.Context, name string) error { func (r *MongoRepository) DropIndex(ctx context.Context, name string) error {
if r.collection == nil { collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set") return merrors.NoData("data collection is not set")
} }
if name == "" { if name == "" {
return merrors.InvalidArgument("index name is required", "index.name") return merrors.InvalidArgument("index name is required", "index.name")
} }
return r.collection.Indexes().DropOne(ctx, name) return collection.Indexes().DropOne(ctx, name)
} }
func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) { func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) {
if r.collection == nil { collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if collection == nil {
return nil, merrors.NoData("data collection is not set") return nil, merrors.NoData("data collection is not set")
} }
cursor, err := r.collection.Indexes().List(ctx) cursor, err := collection.Indexes().List(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -8,6 +8,7 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record" "git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" 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/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -17,7 +18,7 @@ import (
type MongoRepository struct { type MongoRepository struct {
collectionName string collectionName string
collection *mongo.Collection source source.Source
} }
func idFilter(id bson.ObjectID) bson.D { func idFilter(id bson.ObjectID) bson.D {
@@ -27,10 +28,23 @@ func idFilter(id bson.ObjectID) bson.D {
} }
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository { func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
return &MongoRepository{ return NewRepository(source.Fixed(db), collection)
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 { func (r *MongoRepository) Collection() string {
@@ -48,8 +62,14 @@ func prepareForWrite(obj storable.Storable) bson.ObjectID {
} }
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error { 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) prepareForWrite(obj)
_, err := r.collection.InsertOne(ctx, obj) _, err = collection.InsertOne(ctx, obj)
if mongo.IsDuplicateKeyError(err) { if mongo.IsDuplicateKeyError(err) {
if getFilter != nil { if getFilter != nil {
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil { if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
@@ -62,6 +82,12 @@ func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, get
} }
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error { 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 { if len(objects) == 0 {
return nil return nil
} }
@@ -72,12 +98,18 @@ func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Sto
docs[i] = obj docs[i] = obj
} }
_, err := r.collection.InsertMany(ctx, docs) _, err = collection.InsertMany(ctx, docs)
return err return err
} }
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error { func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
err := r.collection.FindOne(ctx, filter).Decode(result) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
err = collection.FindOne(ctx, filter).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) { if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData(errMessage) return merrors.NoData(errMessage)
} }
@@ -94,7 +126,13 @@ func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result stor
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) 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 { func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
cursor, err := queryFunc(ctx, r.collection) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
cursor, err := queryFunc(ctx, collection)
if errors.Is(err, mongo.ErrNoDocuments) { if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData("no_items_in_array") return merrors.NoData("no_items_in_array")
} }
@@ -111,7 +149,7 @@ func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc,
} }
} }
return nil return cursor.Err()
} }
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error { func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
@@ -129,8 +167,14 @@ func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Qu
} }
func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error { 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 { if query == nil {
cursor, err := r.collection.Find(ctx, bson.D{}) cursor, err := collection.Find(ctx, bson.D{})
if err != nil { if err != nil {
return err return err
} }
@@ -139,7 +183,7 @@ func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Que
}() }()
return cursor.All(ctx, result) return cursor.All(ctx, result)
} }
cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions()) cursor, err := collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
if err != nil { if err != nil {
return err return err
} }
@@ -154,18 +198,30 @@ func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Que
} }
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error { 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() obj.Update()
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err() return collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
} }
func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) { 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 { if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter") return false, merrors.InvalidArgument("filter is required", "filter")
} }
if obj == nil { if obj == nil {
return false, merrors.InvalidArgument("object is required", "obj") return false, merrors.InvalidArgument("object is required", "obj")
} }
result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj) result, err := collection.ReplaceOne(ctx, query.BuildQuery(), obj)
if mongo.IsDuplicateKeyError(err) { if mongo.IsDuplicateKeyError(err) {
return false, merrors.DataConflict("duplicate_key") return false, merrors.DataConflict("duplicate_key")
} }
@@ -176,21 +232,39 @@ func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.
} }
func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error { 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) id := prepareForWrite(obj)
_, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true)) _, err = collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
return err return err
} }
func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error { 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() { if id.IsZero() {
return merrors.InvalidArgument("zero id provided while patching", "id") return merrors.InvalidArgument("zero id provided while patching", "id")
} }
_, err := r.collection.UpdateByID(ctx, id, patch.Build()) _, err = collection.UpdateByID(ctx, id, patch.Build())
return err return err
} }
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) { func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
result, err := collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -198,10 +272,16 @@ func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, pa
} }
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) { 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() filter := query.BuildQuery()
findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1}) findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1})
cursor, err := r.collection.Find(ctx, filter, findOptions) cursor, err := collection.Find(ctx, filter, findOptions)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -227,24 +307,45 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b
} }
func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error { func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, idFilter(id)) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteOne(ctx, idFilter(id))
return err return err
} }
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error { func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
_, err := r.collection.DeleteMany(ctx, query.BuildQuery()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteMany(ctx, query.BuildQuery())
return err return err
} }
func (r *MongoRepository) Name() string { func (r *MongoRepository) Name() string {
return r.collection.Name() return r.collectionName
} }
func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { 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 { if cb == nil {
return nil, merrors.InvalidArgument("transaction callback is required", "callback") return nil, merrors.InvalidArgument("transaction callback is required", "callback")
} }
session, err := r.collection.Database().Client().StartSession() if transaction.ActiveMongoTransaction(ctx) {
return cb(ctx)
}
session, err := collection.Database().Client().StartSession()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -12,8 +12,14 @@ import (
) )
func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { 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) opts := query.BuildOptions().SetLimit(1)
cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts) cursor, err := collection.Find(ctx, query.BuildQuery(), opts)
if err != nil { if err != nil {
return err return err
} }
@@ -30,10 +36,22 @@ func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Q
} }
func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) { func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) {
return r.collection.CountDocuments(ctx, query.BuildQuery()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
return collection.CountDocuments(ctx, query.BuildQuery())
} }
func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) { 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 { if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter") return false, merrors.InvalidArgument("filter is required", "filter")
} }
@@ -43,7 +61,7 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
if result == nil { if result == nil {
return false, merrors.InvalidArgument("result is required", "result") return false, merrors.InvalidArgument("result is required", "result")
} }
err := r.collection.FindOneAndUpdate( err = collection.FindOneAndUpdate(
ctx, ctx,
query.BuildQuery(), query.BuildQuery(),
patch.Build(), patch.Build(),
@@ -1,18 +1,15 @@
package transactionimp package transactionimp
import ( import (
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/mongo"
) )
type MongoTransactionFactory struct { type MongoTransactionFactory struct{ database source.Source }
client *mongo.Client
}
func (mtf *MongoTransactionFactory) CreateTransaction() transaction.Transaction { func (f *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
return Create(mtf.client) return &MongoTransaction{database: f.database}
} }
func CreateSourceFactory(database source.Source) transaction.Factory {
func CreateFactory(client *mongo.Client) transaction.Factory { return &MongoTransactionFactory{database: database}
return &MongoTransactionFactory{client: client}
} }
@@ -3,32 +3,29 @@ package transactionimp
import ( import (
"context" "context"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/mongo" merrors "git.sendico.io/tech/sxchnge/shared/errors"
) )
type MongoTransaction struct { type MongoTransaction struct{ database source.Source }
client *mongo.Client
}
func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { func (t *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
// Reuse an outer customer workflow transaction when a token store joins it. 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()
if transaction.ActiveMongoTransaction(ctx) { if transaction.ActiveMongoTransaction(ctx) {
return cb(ctx) return cb(ctx)
} }
session, err := mt.client.StartSession() session, err := database.Client().StartSession()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer session.EndSession(ctx) 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,6 +8,7 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
@@ -53,6 +54,11 @@ type Repository interface {
Collection() string 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 { func CreateMongoRepository(db *mongo.Database, collection string) Repository {
return repositoryimp.NewMongoRepository(db, collection) return CreateRepository(source.Fixed(db), collection)
} }
+275
View File
@@ -0,0 +1,275 @@
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))
}
+253
View File
@@ -0,0 +1,253 @@
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()
}
}
+30
View File
@@ -0,0 +1,30 @@
// 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
}
+3 -3
View File
@@ -6,12 +6,12 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record" "git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "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/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
) )
type CascadeDeleterT = func(ctx context.Context, objectRef bson.ObjectID) error 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]( func Create[T storable.Storable](
logger mlogger.Logger, logger mlogger.Logger,
collection mservice.Type, collection mservice.Type,
db *mongo.Database, db source.Source,
) *DBImp[T] { ) *DBImp[T] {
res := &DBImp[T]{ res := &DBImp[T]{
Logger: logger.Named(collection), Logger: logger.Named(collection),
Repository: repository.CreateMongoRepository(db, collection), Repository: repository.CreateRepository(db, collection),
} }
res.cdeleter = res.deleteCascadeStub res.cdeleter = res.deleteCascadeStub
return res return res
+140
View File
@@ -0,0 +1,140 @@
// 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
}
@@ -0,0 +1,79 @@
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())
})
}
}
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record" "git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" 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/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -55,7 +55,7 @@ type DB struct {
logger mlogger.Logger logger mlogger.Logger
} }
func Create(logger mlogger.Logger, database *mongo.Database, collection string) (*DB, error) { func Create(logger mlogger.Logger, database source.Source, collection string) (*DB, error) {
if database == nil { if database == nil {
return nil, merrors.InvalidArgument("database is required", "database") return nil, merrors.InvalidArgument("database is required", "database")
} }
@@ -64,7 +64,7 @@ func Create(logger mlogger.Logger, database *mongo.Database, collection string)
collection = defaultCollection collection = defaultCollection
} }
db := &DB{ db := &DB{
repo: repository.CreateMongoRepository(database, collection), repo: repository.CreateRepository(database, collection),
logger: logger.Named("rate_limits"), logger: logger.Named("rate_limits"),
} }
if err := db.ensureIndexes(); err != nil { if err := db.ensureIndexes(); err != nil {
+156
View File
@@ -0,0 +1,156 @@
# Second-factor service
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40),
[PR #41](https://git.sendico.io/tech/sxchnge/pulls/41), branch `twofactor-40`.
## Ownership
`backend/product/twofactor` is a domain service inside the product executable.
It owns verified factors, preferred delivery channels, challenge snapshots,
issuance limits, code verification and single-use authorization proofs.
Its domain has no dependency on auth HTTP, JWTs or sessions. Shared Mongo
wrappers supply persistence and transactions; notifications only deliver messages.
Auth verifies the password, account and client, creates a pending JWT bound to the
client/device, and passes its hash as the login operation context. The service
selects the account's verified preferred factor and returns a masked challenge.
Auth calls `Complete` with the code and a callback that creates the session.
The service verifies the code, issues and consumes a proof, and commits the
callback's database changes in one transaction. Rejected guesses commit attempt
counters; callback failures roll back the code, proof and session together.
The HTTP response is written only after commit.
`Verify` and `Consume` also support separate domain steps for an operation that
needs a short-lived proof. Proofs bind the customer, purpose, operation and
challenge; they expire within two minutes and never outlive the challenge.
`Consume` checks the current profile and consumes the proof with the owner's
callback. Callbacks must only use this shared Mongo transaction: no external I/O,
response writes or irreversible side effects. Mongo may retry the callback.
An eventual remote service would need an explicit distributed completion protocol;
the callback contract cannot simply be translated into an RPC.
Signup email activation remains in `auth/flow`, with separate purpose and link
semantics. API-client secret login retains its existing separate behavior.
No generic public endpoint can choose an arbitrary authorization purpose.
## Sendico reuse
The token engine is the existing locally owned copy in
`backend/product/verification/store`. Its source is Sendico
`S_I/sendico` commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`, already recorded
in `backend/PROVENANCE.json` and `backend/PROVENANCE.md`. The new service uses
`NewOTPRequest`, `NewLinkRequest`, `CreateWithResult`, `Consume`, context binding,
idempotency, cooldown, max retries and issuance limits from that engine. It
retains SXCHNGE's subsequent random-key, empty-target-query and safe-log fixes.
No alternative OTP generator or token-consumption database was introduced.
The committed Sendico handlers inspected under
`api/edge/product/internal/server/verificationimp/` include `request.go`,
`verify.go`, `sendcode.go`, `target.go`, `service.go`, `types.go` and `token.go`.
They mix verification with session creation and private platform messaging and
allow recipient resolution unsuitable for a verified-factor boundary. Those
handlers were not copied. The orchestration, profile store, delivery interface
and HTTP settings adapter are new local code. There is no Sendico runtime,
private messaging or database dependency.
## Factors and policy
An active account's activated primary email is the initial verified factor.
Profiles are persisted in `two_factor_profiles`; challenge snapshots in
`two_factor_challenges` bind the chosen factor, locale, profile revision and
operation deadline. Snapshots cannot redirect a code during resend. Profile
changes invalidate outstanding challenges and proofs. A transactional profile
write serializes proof consumption against concurrent preference changes.
Challenge documents expire through a TTL index one day after their logical expiry;
logical expiry is checked synchronously and does not depend on TTL cleanup.
No existing customer migration is required: the initial profile is resolved
from the verified account and persisted on first completed verification.
Email is the installed channel driver. Users can enroll an additional email,
prove ownership and select it. Selection requires a fresh code at the current
preferred factor; enrollment alone cannot replace it. At most five factors
(including the primary email) are enrolled. Registered drivers form the supported
channel allow-list; unsupported or unavailable channels fail closed. There is no
automatic fallback, disable endpoint, factor removal or account recovery flow.
SMS, push, authenticator apps and passkeys require concrete implementations and
client support before use. The Flutter settings screen is still the existing
preview; these authenticated settings APIs are ready for a separate UI adapter.
Login, payout and preference scopes are recognized internally. Payout integration
is not added here; the payout owner must validate its immutable operation details
and call this service. An auth proof cannot authorize money operations.
## HTTP settings contract
All routes below are under `/api/v1/accounts` and require an existing active
account session through the standard `AccountHandler`. Pending login credentials
cannot access them. Account identity and operation scope come from the server,
not request fields. Responses use the existing `data` envelope and `no-store`.
| Method/path | Body | Result (202) |
| --- | --- | --- |
| GET `/two-factor` | none | `required: true`, `preferred`, `factors: [{id, channel, target}]` |
| POST `/two-factor/enroll` | `channel`, `address`, `idempotencyKey` | Challenge at the proposed address |
| POST `/two-factor/enroll/verify` | `challengeId`, `code` | Settings after ownership verification |
| POST `/two-factor/preference` | `factorId`, `idempotencyKey` | Challenge at the current preferred factor |
| POST `/two-factor/preference/verify` | `factorId`, `challengeId`, `code` | Settings after preference changes |
| POST `/two-factor/resend` | `challengeId`, `idempotencyKey` | Same settings operation and target, renewed code |
Challenge responses contain `challengeId`, `channel`, masked `target`, `expiresAt`
and `resendAt`. Login responses additionally include the pending JWT; login
resend/verify now require both `challengeId` and `pendingToken`. Codes, raw proofs
and full enrolled addresses are never returned in these responses.
Codes expire within ten minutes with five guesses per issued code. New issuance
uses a one-minute cooldown per challenge, plus shared fixed-window limits of five
per customer and twenty per requesting IP per hour across purposes. New challenges
cannot reset these delivery budgets. Same-key delivery retries recover the same
code, notification key and expiry; notification enqueueing deduplicates them.
A new accepted resend invalidates the prior code. A locked/expired operation must
restart. Replaying a used code or proof cannot execute the callback again.
## Rollout and recovery
Load revision 1 of `twofactor.code` alongside `auth.activation` and `auth.login`
from `backend/notifications/templates.example.json`; all have English/Russian
variants. Existing notification TLS/caller configuration and the private signing
key supply delivery and secret-derived issuance keys. No new service deployment
or network credentials are required. Mongo must support transactions (replica set).
The application creates the two new collections/index on startup.
Deploy backend and Flutter contract changes together. Earlier pending login codes
have no new challenge record and must restart password login after rollout.
Existing sessions and activation links are preserved. Key rotation invalidates
outstanding factor snapshots/proofs. Keep factors on an available driver when
changing configured drivers; there is no silent fallback.
A failed session write permits retrying the same code. A lost HTTP response after
a successful commit cannot replay the operation: restart login. Enrollment and
preference confirmation can be reconciled with GET settings after an ambiguous
response. Tokens and challenges commit before notification Send; a product crash
before Send still requires resend. No product delivery outbox or inbox receipts
are claimed. No live emails were sent during tests.
## Verification and observability
Real Mongo tests cover failed delivery replay, scope/customer isolation, expiry,
inactive accounts, attempts committed through outer transactions, proof replay,
callback rollback, concurrent completion, enrollment and preference invalidation.
The signup/login integration test exercises the new auth boundary. Transport
checks reject caller identity/purpose/destination overrides. Architecture tests
prevent the 2FA domain importing auth or sessions. Flutter adapter tests exercise
server challenge binding, resend recovery and unsupported channels.
Existing auth HTTP counters/timers and frontend journey events remain authoritative
for login outcomes. Domain logs record queued challenges, rejected codes and
completed operations with challenge IDs and purpose/channel, without codes,
proofs, keys or destinations. Prepared proof logs are debug-level and do not
represent a committed operation. See `docs/ux/auth-journeys.md` for attempt windows,
retry/abandonment rules and the existing production analytics export gap.
No new settings UI journey or production analytics sink is claimed.
The UI composition is unchanged. The shared DTO/mapper/service adapter preserves
masked delivery targets, provider error/retry handling and in-memory credential
storage. Existing widget tests cover mobile forms, keyboard behavior, loading,
cooldown and recovery; new adapter tests cover the changed backend contract.
Required completion checks and delivery are recorded in `docs/implementation.md`.
+146
View File
@@ -0,0 +1,146 @@
# Signup and login email integration
Task [#38](https://git.sendico.io/tech/sxchnge/issues/38), [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39), branch `auth-38`.
Primary module: auth; secondary modules: notification templates and Flutter auth.
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.
+291
View File
@@ -497,3 +497,294 @@ in [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), which remains unmerge
an isolated clean checkout containing this contribution. Shared-workspace runs an isolated clean checkout containing this contribution. Shared-workspace runs
initially failed because concurrent product/auth work had missing module initially failed because concurrent product/auth work had missing module
dependencies and unformatted files; that work was excluded and left untouched. 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).
+197
View File
@@ -0,0 +1,197 @@
# 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.
+3
View File
@@ -46,3 +46,6 @@ app.*.map.json
# Widget Preview related # Widget Preview related
.widget_preview/ .widget_preview/
# Localization bindings are regenerated from ARB sources.
/lib/generated/i18n/
+1 -1
View File
@@ -32,7 +32,7 @@ From the repository root, `make test-frontend` runs both theme suites and the ap
## English and Russian ## English and Russian
Localization follows Sendico's Flutter pattern: `lib/l10n/en.arb` is the template, `ru.arb` contains Russian translations, and `flutter gen-l10n` writes `lib/generated/i18n/`. Edit ARB files, not generated Dart files. Flutter's Material and Cupertino localizations are included through the generated delegates. Localization follows Sendico's Flutter pattern: `lib/l10n/en.arb` is the template, `ru.arb` contains Russian translations, and `flutter gen-l10n` writes `lib/generated/i18n/`. Generated Dart files are ignored and untracked. Run `flutter gen-l10n` after a clean checkout and after editing ARB files; `make test-frontend` runs it before tests. Edit ARB files, not generated Dart files. Flutter's Material and Cupertino localizations are included through the generated delegates.
The language dropdown is available in both authentication modes. Startup reads `sxchnge_last_locale` using shared preferences; without a saved choice it uses the device language. Russian regional variants resolve to Russian and unsupported languages fall back to English. Preference writes are serialized, and storage failures leave the selected language usable for the current session with a localized notice. Only the language preference is stored; credentials remain transient. 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.
+1
View File
@@ -19,6 +19,7 @@ class AuthFeedback extends StatelessWidget {
? (signUp ? l.signupPreview : l.loginPreview) ? (signUp ? l.signupPreview : l.loginPreview)
: l.authCompleted) : l.authCompleted)
: switch (error) { : switch (error) {
AuthError.accountNotVerified => l.authVerifyEmail,
AuthError.invalidCredentials => l.authInvalidCredentials, AuthError.invalidCredentials => l.authInvalidCredentials,
AuthError.emailInUse => l.authEmailInUse, AuthError.emailInUse => l.authEmailInUse,
AuthError.unavailable => l.authUnavailable, AuthError.unavailable => l.authUnavailable,
+6
View File
@@ -104,6 +104,11 @@ class _AuthFormState extends State<AuthForm> {
locale: locale, locale: locale,
); );
if (!mounted || _signUp) return; 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; final challenge = login.state.result?.challenge;
if (challenge != null) { if (challenge != null) {
_password.clear(); _password.clear();
@@ -329,6 +334,7 @@ class _AuthFormState extends State<AuthForm> {
AuthFeedback(state: state, signUp: _signUp), AuthFeedback(state: state, signUp: _signUp),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
if (context.read<TwoFactorService>().isSimulated)
TextButton( TextButton(
onPressed: () => context.go('/accounts'), onPressed: () => context.go('/accounts'),
child: Text(l.wEnter), child: Text(l.wEnter),
File diff suppressed because it is too large. Load diff
@@ -1,860 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'SXCHNGE · Your next move';
@override
String get nextChapter => 'YOUR NEXT CHAPTER';
@override
String get loginHeading => 'Hey, welcome\nback.';
@override
String get signupHeading => 'Make your\nfirst move.';
@override
String get loginIntro => 'Your next move is just a login away.';
@override
String get signupIntro => 'A new account. A world of possibility.';
@override
String get login => 'Log in';
@override
String get signup => 'Sign up';
@override
String get name => 'Your name';
@override
String get email => 'Email address';
@override
String get emailHint => 'you@example.com';
@override
String get password => 'Password';
@override
String get confirmPassword => 'Confirm password';
@override
String get passwordHint => 'Use at least 12 characters.';
@override
String get showPassword => 'Show password';
@override
String get hidePassword => 'Hide password';
@override
String get showConfirmation => 'Show confirmation';
@override
String get hideConfirmation => 'Hide confirmation';
@override
String get createAccount => 'Create account';
@override
String get letsGo => 'Let’s go';
@override
String get loginPreview => 'You’re signed in.';
@override
String get signupPreview => 'Your account is ready.';
@override
String get alreadyMember => 'Already part of sxchnge?';
@override
String get newHere => 'New around here?';
@override
String get loginInstead => 'Log in instead';
@override
String get createAnAccount => 'Create an account';
@override
String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.';
@override
String get brandHeading => 'Good moves.\nStart here.';
@override
String get brandIntro => 'A fresh way forward.\nMake it yours with sxchnge.';
@override
String get brandCard => 'Less friction.\nMore possibility.';
@override
String get brandFooter => 'YOUR NEXT MOVE STARTS WITH YOU.';
@override
String get emailRequired => 'Enter your email address.';
@override
String get emailInvalid => 'Enter a valid email address.';
@override
String get nameRequired => 'Enter your name.';
@override
String get passwordRequired => 'Enter your password.';
@override
String get confirmationRequired => 'Confirm your password.';
@override
String get passwordMismatch => 'Passwords do not match.';
@override
String get language => 'Language';
@override
String get preferenceNotSaved =>
'Language changed for this session. Your preference could not be saved.';
@override
String get authWorking => 'Please wait…';
@override
String get authCompleted => 'Request completed.';
@override
String get authInvalidCredentials =>
'Email or password is incorrect. Please try again.';
@override
String get authEmailInUse =>
'This email is already registered. Try logging in.';
@override
String get authUnavailable => 'The service is unavailable. Please try again.';
@override
String get authUnexpected => 'Something went wrong. Please try again.';
@override
String get checkInbox => 'Check your inbox.';
@override
String get verificationSent =>
'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.';
@override
String get verificationSending => 'Preparing your verification email…';
@override
String get verificationChecking => 'Verifying your email…';
@override
String get verificationVerified => 'Your email\nis verified.';
@override
String get verificationVerifiedBody =>
'One step done. You can now return to login. Identity verification is a separate step.';
@override
String get verificationExpired => 'This link has expired.';
@override
String get verificationExpiredBody =>
'Request a new verification email and use the latest link.';
@override
String get verificationInvalid => 'This link isn’t valid.';
@override
String get verificationInvalidBody =>
'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.';
@override
String get verificationInternal => 'Something went wrong.';
@override
String get verificationInternalBody =>
'We couldn’t complete your request because of an internal error. Please try again.';
@override
String get verificationResend => 'Resend verification email';
@override
String get verificationRequest => 'Send a new verification email';
@override
String get verificationRetry => 'Retry verification';
@override
String get verificationBack => 'Back to login';
@override
String get verificationHint =>
'No email? Check your spam folder or request another below.';
@override
String get verificationDemoLink => 'Continue to confirmation';
@override
String get verificationStep => 'CONFIRM YOUR EMAIL';
@override
String get verificationRecipient => 'Recipient';
@override
String get verificationChangeEmail => 'Change email';
@override
String get kycTitle => 'Verification & limits';
@override
String get kycIntro =>
'Your account, at your pace. Verify your identity when you need higher limits.';
@override
String get kycUnverified => 'Not verified';
@override
String get kycPending => 'In review';
@override
String get kycApproved => 'Identity verified';
@override
String get kycChanges => 'New photos needed';
@override
String get kycDenied => 'Verification declined';
@override
String get kycUnverifiedBody =>
'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.';
@override
String get kycPendingBody =>
'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.';
@override
String get kycApprovedBody =>
'Your review is complete. Check your current limits below.';
@override
String get kycChangesBody =>
'Your account remains available with basic limits. Replace the photos and submit a new review request.';
@override
String get kycDeniedBody =>
'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.';
@override
String get kycPassportReason =>
'The passport details could not be read. Retake the photo in good light, with every edge visible.';
@override
String get kycSelfieReason =>
'Your face or passport was not clear in the selfie. Keep both visible and in focus.';
@override
String get kycExpiredReason =>
'The passport has expired. Use a valid passport and take both photos again.';
@override
String get kycDeniedReason =>
'We could not verify your identity from this submission.';
@override
String get kycStart => 'Verify identity';
@override
String get kycResubmit => 'Replace photos';
@override
String get kycRefresh => 'Refresh status & limits';
@override
String get kycSupport => 'Contact support';
@override
String get kycSupportBody =>
'Support is currently unavailable. Please try again later.';
@override
String get kycBasic => 'Basic';
@override
String get kycVerifiedTier => 'Verified';
@override
String get kycLimits => 'Your limits';
@override
String get kycDay => 'Daily volume';
@override
String get kycMonth => 'Monthly volume';
@override
String get kycCurrent => 'Current cap';
@override
String get kycRemaining => 'Available';
@override
String get kycAfter => 'After approval';
@override
String get kycLimitsError =>
'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.';
@override
String get kycLimitsEmpty =>
'No limits were returned. Refresh or check with support before making a transaction.';
@override
String get kycLoading => 'Loading your status…';
@override
String get kycPassport => 'Passport photo';
@override
String get kycSelfie => 'Selfie with passport';
@override
String get kycReview => 'Review your photos';
@override
String get kycPassportHelp =>
'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.';
@override
String get kycSelfieHelp =>
'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.';
@override
String get kycFileRules =>
'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.';
@override
String get kycChoose => 'Choose photo';
@override
String get kycReplace => 'Choose a different photo';
@override
String get kycRemove => 'Remove photo';
@override
String get kycNext => 'Continue';
@override
String get kycBack => 'Back';
@override
String get kycSkip => 'Keep basic limits';
@override
String get kycClose => 'Back to account';
@override
String get kycReviewHelp =>
'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.';
@override
String get kycConfirm => 'These photos show me and my own valid passport.';
@override
String get kycSubmit => 'Submit for review';
@override
String get kycWorking => 'Processing… Please keep this page open.';
@override
String get kycDiscardTitle => 'Discard these photos?';
@override
String get kycDiscardBody =>
'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.';
@override
String get kycStay => 'Keep editing';
@override
String get kycDiscard => 'Discard & leave';
@override
String get kycErrorUnavailable =>
'The service is unavailable. Your selected photos are still here. Try again.';
@override
String get kycErrorInternal =>
'Something went wrong. Try again; your selected photos have been kept.';
@override
String get kycErrorPermission =>
'We could not open that photo. Check file access or choose another image.';
@override
String get kycErrorFormat =>
'Choose a JPEG or PNG image. Other formats are not supported.';
@override
String get kycErrorLarge =>
'This image is too large. Choose one under 10 MB and 40 megapixels.';
@override
String get kycErrorUnreadable =>
'This image is damaged or cannot be read. Choose another photo.';
@override
String get kycErrorSmall =>
'The photo is too small. Use an image at least 600 pixels wide and high.';
@override
String get kycErrorIncomplete =>
'Choose both photos and confirm they belong to you before submitting.';
@override
String get kycErrorSession =>
'Your session has expired. Return to login, then check your verification status.';
@override
String get kycErrorRate =>
'Too many requests. Wait a moment before trying again.';
@override
String get kycErrorConflict =>
'Your review status has changed. Refresh it before continuing.';
@override
String get kycDemoControls => 'Review scenarios';
@override
String get kycDemoApprove => 'Approve';
@override
String get kycDemoCorrect => 'Request clearer passport';
@override
String get kycDemoSelfie => 'Request clearer selfie';
@override
String get kycDemoExpired => 'Passport expired';
@override
String get kycDemoDeny => 'Decline';
@override
String get kycPreview => 'Explore verification & limits';
@override
String get kycChooseFirst => 'Choose a photo to continue.';
@override
String get kycStatusStale =>
'Status could not be refreshed. The status shown is the last known result.';
@override
String get kycUsed => 'Used';
@override
String get kycReserved => 'Reserved';
@override
String get kycUsageHelp =>
'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.';
@override
String get kycOccupied => 'Used + reserved';
@override
String get kycNoCapacity => 'No capacity available';
@override
String get wAccounts => 'Accounts';
@override
String get wActivity => 'Activity';
@override
String get wLimits => 'Limits';
@override
String get wSettings => 'Settings';
@override
String get wHeadline => 'Your money.\nYour next move.';
@override
String get wSubheading => 'Top up. Exchange. Go further.';
@override
String get wAvailable => 'Available to you';
@override
String get wTopUp => 'Top up';
@override
String get wExchange => 'Exchange';
@override
String get wPayout => 'Pay out';
@override
String get wRecent => 'Recent activity';
@override
String get wSeeAll => 'See all';
@override
String get wEmptyActivity =>
'Your next move starts here. Your transactions will appear below.';
@override
String get wEmptyAccounts =>
'Your accounts are not ready yet. Try refreshing.';
@override
String get wRefresh => 'Refresh';
@override
String get wSource => 'From account';
@override
String get wDestination => 'To account';
@override
String get wReceiveAccount => 'Receiving account';
@override
String get wAmount => 'Amount';
@override
String get wRecipient => 'Recipient';
@override
String get wRecipientHint => 'Recipient name or reference';
@override
String get wReview => 'Review';
@override
String get wReviewTitle => 'One last look.';
@override
String get wFee => 'Fee';
@override
String get wTotalDebit => 'Leaves your account';
@override
String get wCredit => 'Arrives in your account';
@override
String get wRecipientGets => 'Recipient receives';
@override
String get wYouPay => 'You pay';
@override
String get wRate => 'Exchange rate';
@override
String get wQuoteNotice =>
'These terms are valid for 60 seconds. Confirm them before continuing.';
@override
String get wExpired =>
'This quote expired. Refresh it and review the new terms.';
@override
String get wNewQuote => 'Refresh quote';
@override
String get wConfirm => 'Confirm transaction';
@override
String get wEdit => 'Edit details';
@override
String get wClose => 'Close';
@override
String get wWorking => 'Working on it…';
@override
String get wPending => 'Processing';
@override
String get wCompleted => 'Completed';
@override
String get wFailed => 'Not completed';
@override
String get wPendingTopUp =>
'Your top-up is processing. Your available balance has not changed yet.';
@override
String get wPendingExchange =>
'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.';
@override
String get wPendingPayout =>
'The amount and fee have left your available balance. The payout is still processing.';
@override
String get wCompleteTopUp =>
'Your account has been credited. The money is available to use.';
@override
String get wCompleteExchange =>
'Your exchange is complete. The money is available in the receiving account.';
@override
String get wCompletePayout =>
'Your payout is complete. The amount and fee have been deducted from your account.';
@override
String get wFailedTopUp =>
'The top-up did not complete. No money was added to your account.';
@override
String get wFailedOutgoing =>
'The transaction did not complete. The amount and fee are available in your account again.';
@override
String get wUnavailable =>
'Could not refresh. The displayed information may be out of date. Try again.';
@override
String get wInvalidAmount =>
'Enter an amount greater than zero, using the currency’s decimal precision.';
@override
String get wInsufficient =>
'Not enough available money for this amount and fee. Top up or enter a smaller amount.';
@override
String get wLimitError =>
'This amount exceeds your available limit. Check your limits before continuing.';
@override
String get wRecipientError =>
'Enter a sample recipient between 6 and 140 characters.';
@override
String get wUnsupported =>
'This combination is unavailable. Choose another account.';
@override
String get wNotFound =>
'This item could not be found. Return to your accounts.';
@override
String get wUncertain =>
'We could not confirm the outcome. Check it before trying another transaction.';
@override
String get wCheckOutcome => 'Check outcome';
@override
String get wNoSubmission =>
'No transaction was found. You can review and confirm again.';
@override
String get wDiscardTitle => 'Leave this transaction?';
@override
String get wDiscardBody =>
'Your entered details will be cleared. No new transaction will be submitted.';
@override
String get wStay => 'Keep editing';
@override
String get wAll => 'All';
@override
String get wSearch => 'Search by reference';
@override
String get wNoMatches => 'No matching transactions. Try another filter.';
@override
String get wClearFilters => 'Clear filters';
@override
String get wReference => 'Reference';
@override
String get wWhen => 'Created';
@override
String get wViewAccount => 'View account';
@override
String get wPreferences => 'Make it yours.';
@override
String get wLanguage => 'Account language';
@override
String get wLanguageHelp => 'Used throughout your account.';
@override
String get wLanguageSaved => 'Saved to your account.';
@override
String get wLanguageFailed =>
'Changed for this session, but not saved to your account. Retry below.';
@override
String get wProfileError =>
'Account preferences could not be loaded. Try again.';
@override
String get wSecurity => 'Security';
@override
String get wSecurityBody =>
'Password and security settings are not available yet.';
@override
String get wIdentity => 'Identity verification';
@override
String get wIdentityHelp => 'Optional verification for higher limits.';
@override
String get wBackLogin => 'Back to login';
@override
String get wEnter => 'Explore your accounts';
@override
String get wUsage => 'Used this period';
@override
String get wLimitsScope => 'Outgoing activity · EUR equivalent';
@override
String get wPendingHint =>
'Refresh to check the latest status. You can keep using your other accounts.';
@override
String get wFundsHeading => 'Where is my money?';
@override
String get wAnother => 'Make another transaction';
@override
String get factorTitle => 'One more check.';
@override
String get factorLogin => 'Confirm it’s you to finish signing in.';
@override
String get factorMoney =>
'Confirm this transaction. No money moves before confirmation.';
@override
String get factorEnableHelp =>
'Confirm to turn on two-factor verification for sign-in and money operations.';
@override
String get factorDisableHelp =>
'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.';
@override
String get factorEmail => 'Enter the six-digit code sent to your email.';
@override
String get factorAuthenticator =>
'Enter the current six-digit code from your authenticator app.';
@override
String get factorCode => 'Verification code';
@override
String get factorConfirm => 'Verify and continue';
@override
String get factorResend => 'Send a new code';
@override
String get factorHelp =>
'Check your inbox and spam folder. A new code replaces the previous one.';
@override
String get factorAppHelp =>
'Use the current code. If you cannot access your authenticator, cancel this action.';
@override
String get factorInvalid =>
'That code is not valid. Check all six digits and try again.';
@override
String get factorExpired =>
'This verification has expired. Request a new code or return to start again.';
@override
String get factorLocked =>
'Too many incorrect attempts. Wait five minutes, then start verification again.';
@override
String get factorWait => 'Please wait before requesting another code.';
@override
String get factorRequired =>
'Verification is needed again. Please confirm this action once more.';
@override
String get factorUnavailable =>
'Could not complete verification. Try again or cancel this action.';
@override
String factorCooldown(int seconds) {
return 'Send a new code in ${seconds}s';
}
@override
String get factorSettings => 'Two-factor verification';
@override
String get factorSettingsHelp =>
'Add a verification code when you sign in, top up, exchange or pay out.';
@override
String get factorOn => 'On';
@override
String get factorOff => 'Off';
@override
String get factorEnable => 'Turn on';
@override
String get factorDisable => 'Turn off';
@override
String get factorEmailMethod => 'Email code';
@override
String get factorAppMethod => 'Authenticator app';
@override
String get factorAppExpired =>
'This verification has expired. Return and start again.';
}
@@ -1,857 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Russian (`ru`).
class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override
String get appTitle => 'SXCHNGE · Ваш следующий шаг';
@override
String get nextChapter => 'НОВАЯ ГЛАВА';
@override
String get loginHeading => 'Рады видеть\nвас снова.';
@override
String get signupHeading => 'Сделайте\nпервый шаг.';
@override
String get loginIntro => 'Войдите, чтобы сделать следующий шаг.';
@override
String get signupIntro => 'Новый аккаунт. Новые возможности.';
@override
String get login => 'Войти';
@override
String get signup => 'Регистрация';
@override
String get name => 'Ваше имя';
@override
String get email => 'Электронная почта';
@override
String get emailHint => 'you@example.com';
@override
String get password => 'Пароль';
@override
String get confirmPassword => 'Повторите пароль';
@override
String get passwordHint => 'Используйте не менее 12 символов.';
@override
String get showPassword => 'Показать пароль';
@override
String get hidePassword => 'Скрыть пароль';
@override
String get showConfirmation => 'Показать повтор пароля';
@override
String get hideConfirmation => 'Скрыть повтор пароля';
@override
String get createAccount => 'Создать аккаунт';
@override
String get letsGo => 'Продолжить';
@override
String get loginPreview => 'Вы вошли в аккаунт.';
@override
String get signupPreview => 'Ваш аккаунт готов.';
@override
String get alreadyMember => 'Уже есть аккаунт sxchnge?';
@override
String get newHere => 'Впервые здесь?';
@override
String get loginInstead => 'Войти в аккаунт';
@override
String get createAnAccount => 'Зарегистрироваться';
@override
String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.';
@override
String get brandHeading => 'Всё начинается\nс перемен.';
@override
String get brandIntro =>
'Новый путь вперёд.\nОткройте его вместе со sxchnge.';
@override
String get brandCard => 'Меньше преград.\nБольше возможностей.';
@override
String get brandFooter => 'СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.';
@override
String get emailRequired => 'Введите электронную почту.';
@override
String get emailInvalid => 'Введите корректный адрес почты.';
@override
String get nameRequired => 'Введите ваше имя.';
@override
String get passwordRequired => 'Введите пароль.';
@override
String get confirmationRequired => 'Повторите пароль.';
@override
String get passwordMismatch => 'Пароли не совпадают.';
@override
String get language => 'Язык';
@override
String get preferenceNotSaved =>
'Язык изменён для этого сеанса. Сохранить выбор не удалось.';
@override
String get authWorking => 'Подождите…';
@override
String get authCompleted => 'Запрос выполнен.';
@override
String get authInvalidCredentials =>
'Неверная почта или пароль. Попробуйте ещё раз.';
@override
String get authEmailInUse =>
'Эта почта уже зарегистрирована. Попробуйте войти.';
@override
String get authUnavailable => 'Сервис недоступен. Попробуйте ещё раз.';
@override
String get authUnexpected => 'Что-то пошло не так. Попробуйте ещё раз.';
@override
String get checkInbox => 'Проверьте почту.';
@override
String get verificationSent =>
'Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.';
@override
String get verificationSending => 'Готовим письмо для подтверждения…';
@override
String get verificationChecking => 'Подтверждаем вашу почту…';
@override
String get verificationVerified => 'Ваша почта\nподтверждена.';
@override
String get verificationVerifiedBody =>
'Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.';
@override
String get verificationExpired => 'Срок действия ссылки истёк.';
@override
String get verificationExpiredBody =>
'Запросите новое письмо и перейдите по ссылке из него.';
@override
String get verificationInvalid => 'Ссылка недействительна.';
@override
String get verificationInvalidBody =>
'Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.';
@override
String get verificationInternal => 'Что-то пошло не так.';
@override
String get verificationInternalBody =>
'Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.';
@override
String get verificationResend => 'Отправить письмо ещё раз';
@override
String get verificationRequest => 'Отправить новое письмо';
@override
String get verificationRetry => 'Повторить подтверждение';
@override
String get verificationBack => 'Вернуться ко входу';
@override
String get verificationHint =>
'Нет письма? Проверьте папку «Спам» или запросите новое ниже.';
@override
String get verificationDemoLink => 'Перейти к подтверждению';
@override
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
@override
String get verificationRecipient => 'Получатель';
@override
String get verificationChangeEmail => 'Изменить почту';
@override
String get kycTitle => 'Верификация и лимиты';
@override
String get kycIntro =>
'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.';
@override
String get kycUnverified => 'Личность не подтверждена';
@override
String get kycPending => 'На проверке';
@override
String get kycApproved => 'Личность подтверждена';
@override
String get kycChanges => 'Нужны новые фото';
@override
String get kycDenied => 'Верификация отклонена';
@override
String get kycUnverifiedBody =>
'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.';
@override
String get kycPendingBody =>
'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.';
@override
String get kycApprovedBody =>
'Проверка завершена. Актуальные лимиты указаны ниже.';
@override
String get kycChangesBody =>
'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.';
@override
String get kycDeniedBody =>
'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.';
@override
String get kycPassportReason =>
'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.';
@override
String get kycSelfieReason =>
'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.';
@override
String get kycExpiredReason =>
'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.';
@override
String get kycDeniedReason =>
'По этой заявке не удалось подтвердить вашу личность.';
@override
String get kycStart => 'Подтвердить личность';
@override
String get kycResubmit => 'Заменить фото';
@override
String get kycRefresh => 'Обновить статус и лимиты';
@override
String get kycSupport => 'Обратиться в поддержку';
@override
String get kycSupportBody => 'Поддержка сейчас недоступна. Попробуйте позже.';
@override
String get kycBasic => 'Базовый';
@override
String get kycVerifiedTier => 'Подтверждённый';
@override
String get kycLimits => 'Ваши лимиты';
@override
String get kycDay => 'Объём за день';
@override
String get kycMonth => 'Объём за месяц';
@override
String get kycCurrent => 'Текущий лимит';
@override
String get kycRemaining => 'Доступно';
@override
String get kycAfter => 'После одобрения';
@override
String get kycLimitsError =>
'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.';
@override
String get kycLimitsEmpty =>
'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.';
@override
String get kycLoading => 'Загружаем ваш статус…';
@override
String get kycPassport => 'Фото паспорта';
@override
String get kycSelfie => 'Селфи с паспортом';
@override
String get kycReview => 'Проверьте фото';
@override
String get kycPassportHelp =>
'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.';
@override
String get kycSelfieHelp =>
'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.';
@override
String get kycFileRules =>
'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.';
@override
String get kycChoose => 'Выбрать фото';
@override
String get kycReplace => 'Выбрать другое фото';
@override
String get kycRemove => 'Удалить фото';
@override
String get kycNext => 'Продолжить';
@override
String get kycBack => 'Назад';
@override
String get kycSkip => 'Оставить базовые лимиты';
@override
String get kycClose => 'К аккаунту';
@override
String get kycReviewHelp =>
'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.';
@override
String get kycConfirm => 'На фото я и мой действующий паспорт.';
@override
String get kycSubmit => 'Отправить на проверку';
@override
String get kycWorking => 'Обработка… Не закрывайте страницу.';
@override
String get kycDiscardTitle => 'Удалить выбранные фото?';
@override
String get kycDiscardBody =>
'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.';
@override
String get kycStay => 'Продолжить';
@override
String get kycDiscard => 'Удалить и выйти';
@override
String get kycErrorUnavailable =>
'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.';
@override
String get kycErrorInternal =>
'Произошла ошибка. Повторите попытку; выбранные фото сохранены.';
@override
String get kycErrorPermission =>
'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.';
@override
String get kycErrorFormat =>
'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.';
@override
String get kycErrorLarge =>
'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.';
@override
String get kycErrorUnreadable =>
'Изображение повреждено или не читается. Выберите другое фото.';
@override
String get kycErrorSmall =>
'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.';
@override
String get kycErrorIncomplete =>
'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.';
@override
String get kycErrorSession =>
'Сессия истекла. Войдите снова и проверьте статус верификации.';
@override
String get kycErrorRate =>
'Слишком много запросов. Немного подождите и повторите попытку.';
@override
String get kycErrorConflict =>
'Статус проверки изменился. Обновите его, прежде чем продолжить.';
@override
String get kycDemoControls => 'Сценарии проверки';
@override
String get kycDemoApprove => 'Одобрить';
@override
String get kycDemoCorrect => 'Запросить чёткое фото паспорта';
@override
String get kycDemoSelfie => 'Запросить чёткое селфи';
@override
String get kycDemoExpired => 'Паспорт просрочен';
@override
String get kycDemoDeny => 'Отклонить';
@override
String get kycPreview => 'Посмотреть верификацию и лимиты';
@override
String get kycChooseFirst => 'Выберите фото для продолжения.';
@override
String get kycStatusStale =>
'Не удалось обновить статус. Показан последний известный результат.';
@override
String get kycUsed => 'Использовано';
@override
String get kycReserved => 'Зарезервировано';
@override
String get kycUsageHelp =>
'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.';
@override
String get kycOccupied => 'Использовано + зарезервировано';
@override
String get kycNoCapacity => 'Доступный объём исчерпан';
@override
String get wAccounts => 'Счета';
@override
String get wActivity => 'История';
@override
String get wLimits => 'Лимиты';
@override
String get wSettings => 'Настройки';
@override
String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.';
@override
String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.';
@override
String get wAvailable => 'Вам доступно';
@override
String get wTopUp => 'Пополнить';
@override
String get wExchange => 'Обменять';
@override
String get wPayout => 'Вывести';
@override
String get wRecent => 'Последние операции';
@override
String get wSeeAll => 'Смотреть все';
@override
String get wEmptyActivity =>
'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.';
@override
String get wEmptyAccounts =>
'Ваши счета ещё не готовы. Попробуйте обновить страницу.';
@override
String get wRefresh => 'Обновить';
@override
String get wSource => 'Со счёта';
@override
String get wDestination => 'На счёт';
@override
String get wReceiveAccount => 'Счёт для зачисления';
@override
String get wAmount => 'Сумма';
@override
String get wRecipient => 'Получатель';
@override
String get wRecipientHint => 'Имя или реквизиты получателя';
@override
String get wReview => 'Проверить';
@override
String get wReviewTitle => 'Проверьте детали.';
@override
String get wFee => 'Комиссия';
@override
String get wTotalDebit => 'Спишется со счёта';
@override
String get wCredit => 'Поступит на счёт';
@override
String get wRecipientGets => 'Получатель получит';
@override
String get wYouPay => 'Вы платите';
@override
String get wRate => 'Курс обмена';
@override
String get wQuoteNotice =>
'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.';
@override
String get wExpired =>
'Срок действия расчёта истёк. Обновите его и проверьте новые условия.';
@override
String get wNewQuote => 'Обновить расчёт';
@override
String get wConfirm => 'Подтвердить операцию';
@override
String get wEdit => 'Изменить данные';
@override
String get wClose => 'Закрыть';
@override
String get wWorking => 'Обрабатываем…';
@override
String get wPending => 'В обработке';
@override
String get wCompleted => 'Завершено';
@override
String get wFailed => 'Не выполнено';
@override
String get wPendingTopUp =>
'Пополнение обрабатывается. Доступный баланс пока не изменился.';
@override
String get wPendingExchange =>
'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.';
@override
String get wPendingPayout =>
'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.';
@override
String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.';
@override
String get wCompleteExchange =>
'Обмен завершён. Деньги доступны на счёте зачисления.';
@override
String get wCompletePayout =>
'Вывод завершён. Сумма и комиссия списаны с вашего счёта.';
@override
String get wFailedTopUp =>
'Пополнение не выполнено. На счёт ничего не зачислено.';
@override
String get wFailedOutgoing =>
'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.';
@override
String get wUnavailable =>
'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.';
@override
String get wInvalidAmount =>
'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.';
@override
String get wInsufficient =>
'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.';
@override
String get wLimitError =>
'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.';
@override
String get wRecipientError =>
'Введите тестового получателя длиной от 6 до 140 символов.';
@override
String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.';
@override
String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.';
@override
String get wUncertain =>
'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.';
@override
String get wCheckOutcome => 'Проверить результат';
@override
String get wNoSubmission =>
'Операция не найдена. Вы можете снова проверить и подтвердить её.';
@override
String get wDiscardTitle => 'Выйти из операции?';
@override
String get wDiscardBody =>
'Введённые данные будут удалены. Новая операция не будет отправлена.';
@override
String get wStay => 'Продолжить';
@override
String get wAll => 'Все';
@override
String get wSearch => 'Поиск по номеру';
@override
String get wNoMatches => 'Операции не найдены. Измените фильтр.';
@override
String get wClearFilters => 'Сбросить фильтры';
@override
String get wReference => 'Номер операции';
@override
String get wWhen => 'Создано';
@override
String get wViewAccount => 'Открыть счёт';
@override
String get wPreferences => 'Настройте под себя.';
@override
String get wLanguage => 'Язык аккаунта';
@override
String get wLanguageHelp => 'Используется во всём аккаунте.';
@override
String get wLanguageSaved => 'Сохранено в аккаунте.';
@override
String get wLanguageFailed =>
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.';
@override
String get wProfileError =>
'Не удалось загрузить настройки аккаунта. Повторите попытку.';
@override
String get wSecurity => 'Безопасность';
@override
String get wSecurityBody =>
'Настройки пароля и безопасности пока недоступны.';
@override
String get wIdentity => 'Подтверждение личности';
@override
String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.';
@override
String get wBackLogin => 'Вернуться ко входу';
@override
String get wEnter => 'Открыть свои счета';
@override
String get wUsage => 'Использовано за период';
@override
String get wLimitsScope => 'Исходящие операции · эквивалент в EUR';
@override
String get wPendingHint =>
'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.';
@override
String get wFundsHeading => 'Где мои деньги?';
@override
String get wAnother => 'Создать новую операцию';
@override
String get factorTitle => 'Ещё одна проверка.';
@override
String get factorLogin => 'Подтвердите, что это вы, чтобы завершить вход.';
@override
String get factorMoney =>
'Подтвердите эту операцию. До подтверждения деньги не переводятся.';
@override
String get factorEnableHelp =>
'Подтвердите включение двухфакторной проверки для входа и операций с деньгами.';
@override
String get factorDisableHelp =>
'Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.';
@override
String get factorEmail => 'Введите шестизначный код из письма.';
@override
String get factorAuthenticator =>
'Введите текущий шестизначный код из приложения-аутентификатора.';
@override
String get factorCode => 'Код подтверждения';
@override
String get factorConfirm => 'Подтвердить и продолжить';
@override
String get factorResend => 'Отправить новый код';
@override
String get factorHelp =>
'Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.';
@override
String get factorAppHelp =>
'Используйте текущий код. Если приложение недоступно, отмените это действие.';
@override
String get factorInvalid =>
'Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.';
@override
String get factorExpired =>
'Срок проверки истёк. Запросите новый код или вернитесь и начните снова.';
@override
String get factorLocked =>
'Слишком много неверных попыток. Подождите пять минут и начните проверку снова.';
@override
String get factorWait => 'Подождите перед повторным запросом кода.';
@override
String get factorRequired =>
'Нужна повторная проверка. Подтвердите это действие ещё раз.';
@override
String get factorUnavailable =>
'Не удалось завершить проверку. Повторите попытку или отмените действие.';
@override
String factorCooldown(int seconds) {
return 'Новый код через $seconds с';
}
@override
String get factorSettings => 'Двухфакторная проверка';
@override
String get factorSettingsHelp =>
'Подтверждайте кодом вход, пополнение, обмен и вывод средств.';
@override
String get factorOn => 'Включена';
@override
String get factorOff => 'Выключена';
@override
String get factorEnable => 'Включить';
@override
String get factorDisable => 'Выключить';
@override
String get factorEmailMethod => 'Код из письма';
@override
String get factorAppMethod => 'Приложение-аутентификатор';
@override
String get factorAppExpired =>
'Срок проверки истёк. Вернитесь и начните снова.';
}
+2 -1
View File
@@ -43,10 +43,11 @@
"authCompleted": "Request completed.", "authCompleted": "Request completed.",
"authInvalidCredentials": "Email or password is incorrect. Please try again.", "authInvalidCredentials": "Email or password is incorrect. Please try again.",
"authEmailInUse": "This email is already registered. Try logging in.", "authEmailInUse": "This email is already registered. Try logging in.",
"authVerifyEmail": "Confirm your email before signing in.",
"authUnavailable": "The service is unavailable. Please try again.", "authUnavailable": "The service is unavailable. Please try again.",
"authUnexpected": "Something went wrong. Please try again.", "authUnexpected": "Something went wrong. Please try again.",
"checkInbox": "Check your inbox.", "checkInbox": "Check your inbox.",
"verificationSent": "We’ve sent you a verification email. Open the link in your inbox to confirm your email address.", "verificationSent": "If this email belongs to an unverified account, a verification email is on its way. Open the link in your inbox to confirm your address.",
"verificationSending": "Preparing your verification email…", "verificationSending": "Preparing your verification email…",
"verificationChecking": "Verifying your email…", "verificationChecking": "Verifying your email…",
"verificationVerified": "Your email\nis verified.", "verificationVerified": "Your email\nis verified.",
+2 -1
View File
@@ -43,10 +43,11 @@
"authCompleted": "Запрос выполнен.", "authCompleted": "Запрос выполнен.",
"authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.", "authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.",
"authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.", "authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.",
"authVerifyEmail": "Подтвердите почту перед входом.",
"authUnavailable": "Сервис недоступен. Попробуйте ещё раз.", "authUnavailable": "Сервис недоступен. Попробуйте ещё раз.",
"authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.", "authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.",
"checkInbox": "Проверьте почту.", "checkInbox": "Проверьте почту.",
"verificationSent": "Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.", "verificationSent": "Если эта почта принадлежит неподтверждённому аккаунту, письмо с подтверждением уже в пути. Откройте ссылку в письме, чтобы подтвердить адрес.",
"verificationSending": "Готовим письмо для подтверждения…", "verificationSending": "Готовим письмо для подтверждения…",
"verificationChecking": "Подтверждаем вашу почту…", "verificationChecking": "Подтверждаем вашу почту…",
"verificationVerified": "Ваша почта\nподтверждена.", "verificationVerified": "Ваша почта\nподтверждена.",
+22 -7
View File
@@ -20,13 +20,19 @@ import 'generated/i18n/app_localizations.dart';
Future<void> main() async { Future<void> main() async {
final binding = WidgetsFlutterBinding.ensureInitialized(); final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales); final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
runApp(SxchngeApp(localeProvider: locale)); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
const clientId = String.fromEnvironment('SXCHNGE_CLIENT_ID');
final backend = apiUrl.isEmpty
? null
: BackendAuthServices(baseUri: Uri.parse(apiUrl), clientId: clientId);
runApp(SxchngeApp(localeProvider: locale, backend: backend));
} }
class SxchngeApp extends StatefulWidget { class SxchngeApp extends StatefulWidget {
const SxchngeApp({ const SxchngeApp({
super.key, super.key,
this.localeProvider, this.localeProvider,
this.backend,
this.analyticsService, this.analyticsService,
this.identityService, this.identityService,
this.limitsService, this.limitsService,
@@ -38,6 +44,7 @@ class SxchngeApp extends StatefulWidget {
this.verificationService = const DummyVerificationService(), this.verificationService = const DummyVerificationService(),
}); });
final LocaleProvider? localeProvider; final LocaleProvider? localeProvider;
final BackendAuthServices? backend;
final AnalyticsService? analyticsService; final AnalyticsService? analyticsService;
final IdentityService? identityService; final IdentityService? identityService;
final LimitsService? limitsService; final LimitsService? limitsService;
@@ -56,7 +63,8 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider(); late final _locale = widget.localeProvider ?? LocaleProvider();
late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _factors = widget.twoFactorService ?? DummyTwoFactorService(); late final _factors =
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
late final _security = SecurityProvider(_factors, _analytics); late final _security = SecurityProvider(_factors, _analytics);
late final _identity = widget.identityService ?? DummyIdentityService(); late final _identity = widget.identityService ?? DummyIdentityService();
late final _walletService = late final _walletService =
@@ -79,12 +87,18 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _journeys = JourneyRecorder(_analytics); late final _journeys = JourneyRecorder(_analytics);
late final _services = InstrumentedAuthServices( late final _services = InstrumentedAuthServices(
recorder: _journeys, recorder: _journeys,
loginService: GuardedLoginService(widget.loginService, _factors), loginService: GuardedLoginService(
signupService: widget.signupService, widget.backend ?? widget.loginService,
verificationService: widget.verificationService, _factors,
),
signupService: widget.backend ?? widget.signupService,
verificationService: widget.backend ?? widget.verificationService,
); );
late final _router = createAppRouter() late final _router = createAppRouter(
..routeInformationProvider.addListener(_routeChanged); authenticated: widget.backend == null
? null
: () => widget.backend!.session != null,
)..routeInformationProvider.addListener(_routeChanged);
String _previousPath = '/'; String _previousPath = '/';
@override @override
@@ -109,6 +123,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
_wallet.dispose(); _wallet.dispose();
_security.dispose(); _security.dispose();
_locale.dispose(); _locale.dispose();
widget.backend?.close();
super.dispose(); super.dispose();
} }
+12 -1
View File
@@ -19,12 +19,23 @@ import '../workspace/shell.dart';
import '../workspace/transfer.dart'; import '../workspace/transfer.dart';
import 'routes.dart'; import 'routes.dart';
GoRouter createAppRouter() { GoRouter createAppRouter({bool Function()? authenticated}) {
final topUpKey = GlobalKey<TransferPageState>(); final topUpKey = GlobalKey<TransferPageState>();
final exchangeKey = GlobalKey<TransferPageState>(); final exchangeKey = GlobalKey<TransferPageState>();
final payoutKey = GlobalKey<TransferPageState>(); final payoutKey = GlobalKey<TransferPageState>();
final identityKey = GlobalKey<IdentityPageState>(); final identityKey = GlobalKey<IdentityPageState>();
return GoRouter( 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. // Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(), extraCodec: const _TransientExtras(),
routes: [ routes: [
+58
View File
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
@@ -21,7 +23,63 @@ class ControlledLogin implements LoginService {
} }
} }
class UnverifiedBackend extends BackendAuthServices {
UnverifiedBackend()
: super(baseUri: Uri.parse('https://api.example/api/v1'), clientId: 'web');
int sends = 0;
@override
Future<AuthResult> login(LoginRequest request) async =>
throw const AuthException(AuthError.accountNotVerified);
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
sends++;
return const VerificationResult(isSimulated: false);
}
}
void main() { 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( testWidgets(
'app shows loading, localized failure, and retries through injected service', 'app shows loading, localized failure, and retries through injected service',
(tester) async { (tester) async {
+4 -1
View File
@@ -131,7 +131,10 @@ void main() {
); );
await tap(tester, find.byKey(const ValueKey('submit'))); await tap(tester, find.byKey(const ValueKey('submit')));
expect(find.text('Check your inbox.'), findsOneWidget); expect(find.text('Check your inbox.'), findsOneWidget);
expect(find.textContaining('We’ve sent you'), findsOneWidget); expect(
find.textContaining('a verification email is on its way'),
findsOneWidget,
);
await tap(tester, find.byKey(const ValueKey('demo-confirmation'))); await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
expect(find.text('Your email\nis verified.'), findsOneWidget); expect(find.text('Your email\nis verified.'), findsOneWidget);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1); expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
@@ -0,0 +1,25 @@
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;
}
@@ -0,0 +1,50 @@
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