Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5689876ff | ||
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 | ||
|
|
5d67c25c8e | ||
|
|
b303285b8b | ||
|
|
1354551ae8 | ||
|
|
7bf8b8087a | ||
|
|
a584c8189a | ||
|
|
30f0455122 | ||
|
|
9f9525df56 | ||
|
|
259f84e0a1 | ||
|
|
d04c8b12ab | ||
|
|
ce6583f454 | ||
|
|
caad7a3d5b | ||
|
|
1fe173397f | ||
|
|
9d57a65010 | ||
|
|
9dbf0bd80c | ||
|
|
1b8800ee73 | ||
|
|
23f7b78c3b | ||
|
|
80d6917aa7 | ||
|
|
184a3be413 | ||
|
|
8e2ef7aa88 | ||
|
|
fc33630a0a | ||
|
|
ecb63b61c6 |
No files matched your search
@@ -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__
|
||||
@@ -34,3 +34,9 @@ pnpm-debug.log*
|
||||
/backend/notifications/interface/notifications/**/*.pb.go
|
||||
/backend/notifications/config.yml
|
||||
/backend/notifications/notifications
|
||||
|
||||
# Local container pins, Vault bootstrap material and service identities
|
||||
/.local/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -22,6 +22,22 @@
|
||||
generated outputs. Document the generation workflow so a clean checkout can
|
||||
build without committed generated code.
|
||||
|
||||
## Local development tooling
|
||||
|
||||
Use Make and shell for local development orchestration. Do not introduce Python
|
||||
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
|
||||
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
|
||||
|
||||
## Container versions
|
||||
|
||||
Use only the latest stable upstream releases for infrastructure, product base
|
||||
images, build stages and disposable test containers. Exclude prereleases and
|
||||
vendor mainline channels when a separate stable channel exists. Resolve stable
|
||||
versions when building, pull fresh manifests, and pin the resolved image digests
|
||||
for that local build. Fail when stable release resolution fails; never silently
|
||||
fall back to an older cached version. Keep the reproducible resolver tracked and
|
||||
its generated local pins ignored by Git.
|
||||
|
||||
## Data and state architecture
|
||||
|
||||
Preserve explicit **DTO → mapper → service → data model → provider → UI**
|
||||
@@ -153,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
|
||||
task and durable repository documentation. Merge only when authorized by the
|
||||
user. If work is delivered as stacked PRs, document dependencies and merge order.
|
||||
|
||||
## Local infrastructure ownership
|
||||
|
||||
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
|
||||
configuration and shell tooling under `infra/local/`. Keep product source under
|
||||
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
|
||||
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
|
||||
and bootstrap administration under `infrastructure/<component>/bootstrap`.
|
||||
Do not add BFF-to-notifications caller authentication on the trusted private network.
|
||||
Preserve NATS authentication.
|
||||
Use Caddy for local browser HTTPS and certificate management. Treat the private
|
||||
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
|
||||
local TLS certificates without an explicit requirement.
|
||||
Store only secret values in Vault. Driver selection, sender identities, endpoints
|
||||
and other non-secret runtime settings belong in service configuration files.
|
||||
@@ -17,10 +17,12 @@ test-frontend:
|
||||
.PHONY: test-backend lint-backend run-backend
|
||||
|
||||
test-backend: generate-notifications
|
||||
cd backend/shared && go test -race ./...
|
||||
cd backend/notifications/interface && go test -race ./...
|
||||
cd backend/notifications && go test -race ./...
|
||||
cd backend/product && go test -race ./...
|
||||
docker pull mongo:latest
|
||||
docker pull nats:latest
|
||||
cd backend/shared && go test -race -count=1 ./...
|
||||
cd backend/notifications/interface && go test -race -count=1 ./...
|
||||
cd backend/notifications && go test -race -count=1 ./...
|
||||
cd backend/product && go test -race -count=1 ./...
|
||||
|
||||
lint-backend: generate-notifications
|
||||
cd backend/product && go test ./architecture
|
||||
@@ -49,3 +51,51 @@ package-notifications-interface: generate-notifications
|
||||
mkdir -p dist
|
||||
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
||||
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
|
||||
|
||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||
ifneq ($(wildcard .local/dev/sendico/settings.env),)
|
||||
COMPOSE += -f infra/local/sendico/compose.yml
|
||||
endif
|
||||
.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
|
||||
|
||||
.PHONY: sendico
|
||||
sendico:
|
||||
sh infra/local/tools.sh sendico
|
||||
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
|
||||
localization, email verification and recovery screens, and URL-based navigation
|
||||
with `go_router`. Its interface uses the shared theme's colors and typography.
|
||||
|
||||
**Authentication, email verification and analytics use dummy services.** No real
|
||||
account or session is created, no email is delivered, and no analytics data is
|
||||
transmitted by those app services. The backend now contains locally owned copies
|
||||
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
|
||||
connected to that backend or to Sendico yet.
|
||||
The default preview uses dummy services. Configuring the existing backend URL
|
||||
and client ID selects authenticated BFF adapters for auth, identity, limits and
|
||||
wallet quotations/payment execution. Optional Sendico configuration and dedicated
|
||||
customer financial resources are required for real operations; analytics still
|
||||
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
|
||||
|
||||
## Repository structure
|
||||
|
||||
@@ -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
|
||||
currently included. Local `.vscode/` configuration is ignored.
|
||||
|
||||
## Local Docker development
|
||||
|
||||
```sh
|
||||
make build && make up
|
||||
```
|
||||
|
||||
This builds the product images and starts an isolated Vault, authenticated MongoDB
|
||||
replica set and NATS JetStream. First startup intentionally stops after bootstrap
|
||||
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
|
||||
and the verified sender in `infra/local/notifications/runtime.yml`.
|
||||
Then rerun `make up` to start notifications, BFF and the frontend at
|
||||
[https://localhost:5126](https://localhost:5126).
|
||||
|
||||
All image inputs resolve to current stable releases and are pinned by digest for
|
||||
the build. Services fetch secrets from Vault; Mongo application credentials come
|
||||
from Vault's database plugin. See the [local development guide](docs/local-development.md)
|
||||
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
|
||||
|
||||
## Run the consumer app
|
||||
|
||||
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
|
||||
@@ -54,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
|
||||
repository root:
|
||||
|
||||
```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
|
||||
@@ -237,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
|
||||
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
|
||||
|
||||
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
||||
The copied runtime installs local authentication transport and initializes its
|
||||
account service. Signup/profile endpoints, verification delivery/completion,
|
||||
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
|
||||
[backend run guide](backend/README.md) for local MongoDB configuration.
|
||||
The runtime installs local authentication, verification delivery, identity and
|
||||
wallet transports. Public Sendico adapters support independent customer facts,
|
||||
reviewed identity/limits and quotation/execution through the BFF. Deployment and
|
||||
financial-resource provisioning are separate; backend account-language saving
|
||||
and additional funding/recipient onboarding remain follow-up work. See the
|
||||
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
|
||||
@@ -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
|
||||
removed. Future public adapters will own just the wire types they consume.
|
||||
Backoffice is a separate future executable module, not an alias for product.
|
||||
|
||||
## Second-factor separation (#40)
|
||||
|
||||
The new `product/twofactor` domain service reuses the already copied
|
||||
`product/verification/store` issuance and consumption implementation at the pinned
|
||||
Sendico revision. No additional Sendico source files were copied. Coupled
|
||||
verification HTTP/session/private-messaging handlers were inspected but excluded.
|
||||
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
|
||||
@@ -22,12 +22,12 @@ subject, text and HTML, bounded timeouts and safe error classification.
|
||||
|
||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for
|
||||
each configured caller, and the selected email provider's API key environment
|
||||
variable. Configure a verified sender address, TLS certificate/key and template
|
||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
|
||||
variable. Configure a verified sender address and template
|
||||
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
|
||||
are supported by the shared driver. Mongo and messaging both use shared
|
||||
`DriverConfig` settings; email settings reject unknown keys.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
@@ -15,15 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Queue bus.Config `yaml:"queue"`
|
||||
Messaging messaging.Config `yaml:"messaging"`
|
||||
Listen string `yaml:"listen"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
Database db.Config `yaml:"database"`
|
||||
Templates string `yaml:"templates"`
|
||||
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
|
||||
Email email.Config `yaml:"email"`
|
||||
Queue bus.Config `yaml:"queue"`
|
||||
Messaging messaging.Config `yaml:"messaging"`
|
||||
Listen string `yaml:"listen"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
Database db.Config `yaml:"database"`
|
||||
Templates string `yaml:"templates"`
|
||||
Email email.Config `yaml:"email"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
@@ -40,19 +38,14 @@ func Load(path string) (Config, error) {
|
||||
if err = decoder.Decode(new(any)); err != io.EOF {
|
||||
return c, merrors.InvalidArgument("unexpected configuration content")
|
||||
}
|
||||
if c.Listen == "" || c.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")
|
||||
}
|
||||
if (c.TLSCert == "") != (c.TLSKey == "") {
|
||||
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
|
||||
}
|
||||
if err = c.Queue.Validate(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for caller, env := range c.Callers {
|
||||
token := os.Getenv(env)
|
||||
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
|
||||
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -3,12 +3,10 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
|
||||
t.Fatal("wrong driver configuration")
|
||||
}
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
|
||||
if _, err = Load("../config.example.yml"); err == nil {
|
||||
t.Fatal("missing credentials accepted")
|
||||
}
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -33,3 +26,30 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
t.Fatal("extra YAML document accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := serverOptions(c)
|
||||
if err != nil || len(options) != 0 {
|
||||
t.Fatal("plain gRPC must not require TLS material")
|
||||
}
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("incomplete TLS configuration accepted")
|
||||
}
|
||||
}
|
||||
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
|
||||
t.Fatal("bad explicit certificate accepted")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,9 +32,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||
options, err := serverOptions(c)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||
return err
|
||||
}
|
||||
client, err := db.ConnectMongo(logger, &c.Database)
|
||||
if err != nil {
|
||||
@@ -63,11 +62,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer broker.Close()
|
||||
tokens := map[string]string{}
|
||||
for caller, env := range c.Callers {
|
||||
tokens[caller] = os.Getenv(env)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
|
||||
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
|
||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||
listener, err := net.Listen("tcp", c.Listen)
|
||||
if err != nil {
|
||||
@@ -103,3 +98,15 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
logger.Info("Notifications service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// An omitted certificate pair uses plaintext gRPC on the private service network.
|
||||
func serverOptions(c Config) ([]grpc.ServerOption, error) {
|
||||
if c.TLSCert == "" && c.TLSKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||
}
|
||||
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
listen: "127.0.0.1:8083"
|
||||
tls_cert: "server.crt"
|
||||
tls_key: "server.key"
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
@@ -14,8 +12,6 @@ messaging:
|
||||
max_reconnects: -1
|
||||
reconnect_wait: 2
|
||||
templates: "templates.example.json"
|
||||
callers:
|
||||
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
|
||||
email:
|
||||
driver: resend # sendgrid or resend
|
||||
settings:
|
||||
|
||||
@@ -20,14 +20,14 @@ Internal Go consumers use the module
|
||||
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
|
||||
checkout, use a local replace to this directory and generate before building.
|
||||
|
||||
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service
|
||||
maps tokens to configured caller identities. Callers authorize user/destination
|
||||
Calls use the trusted private network without caller authentication. The service
|
||||
uses the existing product storage namespace. Callers authorize user/destination
|
||||
ownership and preferences before sending; this contract is not a public user API.
|
||||
|
||||
Parameters are string values keyed by template parameter name. The entire request
|
||||
is validated before persistence. An identical idempotency key/request returns the
|
||||
original resource, even after completion or expiry. A changed request conflicts.
|
||||
Keys are scoped to the 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
|
||||
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
|
||||
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Internal service: authenticate callers over TLS. Send durably queues work.
|
||||
// Internal service on the trusted private network; no caller authentication. Send durably queues work.
|
||||
service NotificationsService {
|
||||
rpc Send(SendRequest) returns (SendResponse);
|
||||
rpc Get(GetRequest) returns (GetResponse);
|
||||
}
|
||||
message SendRequest {
|
||||
string idempotency_key = 1; // Required; scoped to 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 template_id = 3;
|
||||
string template_version = 4;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -17,10 +16,9 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/notifications/transport"
|
||||
"google.golang.org/grpc"
|
||||
"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 {
|
||||
t.Helper()
|
||||
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 {
|
||||
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))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return pb.NewNotificationsServiceClient(conn)
|
||||
}
|
||||
func callerContext(ctx context.Context, caller string) context.Context {
|
||||
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
|
||||
}
|
||||
@@ -26,8 +26,6 @@ import (
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type sender struct {
|
||||
@@ -50,7 +48,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
|
||||
func TestDurableDelivery(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8.0")
|
||||
container, err := mongodb.Run(ctx, "mongo:latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -192,7 +190,7 @@ func TestDurableDelivery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Verify the real JetStream publisher/consumer path with an isolated broker.
|
||||
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats: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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
|
||||
}
|
||||
|
||||
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"}}}}}
|
||||
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
|
||||
t.Fatal("unauthenticated request accepted", e)
|
||||
}
|
||||
sent, err := client.Send(rpcCtx, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
|
||||
if err != nil || replay.Notification.Id != sent.Notification.Id {
|
||||
t.Fatal("RPC idempotency failed", err)
|
||||
}
|
||||
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound {
|
||||
t.Fatal("RPC caller isolation failed", e)
|
||||
if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
|
||||
t.Fatal("RPC retrieval without credentials failed", e)
|
||||
}
|
||||
if err = broker.PublishDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ type Store struct {
|
||||
template.DBImp[*model.Notification]
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, db *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)}
|
||||
ttl := int32(0)
|
||||
for _, index := range []*ri.Definition{
|
||||
|
||||
@@ -55,5 +55,25 @@
|
||||
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "twofactor.code",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE verification code",
|
||||
"text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.",
|
||||
"html": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код подтверждения SXCHNGE",
|
||||
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,21 +2,15 @@ package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type callerKey struct{}
|
||||
type API interface {
|
||||
Send(context.Context, string, model.Request) (*model.Notification, error)
|
||||
Get(context.Context, string, string) (*model.Notification, error)
|
||||
@@ -26,14 +20,14 @@ type Server struct {
|
||||
service API
|
||||
}
|
||||
|
||||
// The private endpoint uses the existing product storage namespace without caller authentication.
|
||||
func New(service API) *Server { return &Server{service: service} }
|
||||
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
|
||||
input, err := request(r)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
caller, _ := ctx.Value(callerKey{}).(string)
|
||||
n, err := s.service.Send(ctx, caller, input)
|
||||
n, err := s.service.Send(ctx, "product", input)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
|
||||
if r == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing request")
|
||||
}
|
||||
caller, _ := ctx.Value(callerKey{}).(string)
|
||||
n, err := s.service.Get(ctx, caller, r.Id)
|
||||
n, err := s.service.Get(ctx, "product", r.Id)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &pb.GetResponse{Notification: response(n)}, nil
|
||||
}
|
||||
|
||||
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
|
||||
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
|
||||
hashes := map[string][32]byte{}
|
||||
for caller, token := range tokens {
|
||||
hashes[caller] = sha256.Sum256([]byte(token))
|
||||
}
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
values := md.Get("authorization")
|
||||
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
|
||||
identity := ""
|
||||
for caller, expected := range hashes {
|
||||
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
|
||||
identity = caller
|
||||
}
|
||||
}
|
||||
if identity == "" {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
||||
}
|
||||
return handler(context.WithValue(ctx, callerKey{}, identity), req)
|
||||
}
|
||||
}
|
||||
func rpcError(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return status.Error(codes.Canceled, "notification operation canceled")
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
@@ -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) {
|
||||
return nil, merrors.NoData("private details")
|
||||
}
|
||||
func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
||||
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
||||
listener := bufconn.Listen(1 << 20)
|
||||
token := strings.Repeat("a", 32)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer server.Stop()
|
||||
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
||||
defer conn.Close()
|
||||
client := pb.NewNotificationsServiceClient(conn)
|
||||
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
|
||||
ctx := context.Background()
|
||||
result, err := client.Send(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -28,14 +28,25 @@ Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
Profile HTTP wiring remains follow-up work.
|
||||
API versions are installed explicitly by the product composition. See
|
||||
[version installation and compatibility](../../docs/api-versions.md) for selection,
|
||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||
wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
copy only the wire definitions consumed by an implemented adapter.
|
||||
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||
contracts; local customers retain no platform tenancy. See
|
||||
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||
|
||||
## Validate
|
||||
|
||||
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
|
||||
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
|
||||
validate `shared`. See [source provenance](../PROVENANCE.md).
|
||||
|
||||
## Customer quotations and payments
|
||||
|
||||
With `api.sendico`, runtime composes the customer-scoped payment service and
|
||||
public HTTP provider adapter. Flutter backend mode selects this path automatically;
|
||||
missing provider configuration never returns demo money. See
|
||||
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
|
||||
@@ -1,23 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
LifecycleContext() context.Context
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
ProfileConfig() ConfigProvider
|
||||
DBFactory() db.Factory
|
||||
RuntimeComponent(name string) any
|
||||
Register() Register
|
||||
// Dependencies are initialized once and shared by installed transports.
|
||||
type Dependencies struct {
|
||||
Stores *storage.Stores
|
||||
Customers accountservice.AccountService
|
||||
Auth *flow.Service
|
||||
Factors *twofactor.Service
|
||||
Identity *identity.Service
|
||||
Payments *payments.Service
|
||||
Token middleware.TokenConfig
|
||||
Signature middleware.Signature
|
||||
LoginLimits middleware.LoginRateLimits
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
// Scope belongs to one installation. Router paths are relative to Mount.
|
||||
type Scope struct {
|
||||
Router chi.Router
|
||||
Mount string
|
||||
URLs domainprovider.DomainProvider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
type Installer func(Scope, *Dependencies) error
|
||||
|
||||
type Mount struct {
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
@@ -3,10 +3,14 @@ package api
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Sendico *sendico.Config `yaml:"sendico"`
|
||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||
Versions []Mount `yaml:"versions"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
package api
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/shared/service"
|
||||
import (
|
||||
"context"
|
||||
|
||||
type ConfigProvider interface {
|
||||
SharedConfig() *Config
|
||||
}
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type ConfigProvider interface{ SharedConfig() *Config }
|
||||
type ConfigFactory func() ConfigProvider
|
||||
type RuntimeComponents map[string]any
|
||||
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
|
||||
type RuntimeFinishFunc func(API) error
|
||||
|
||||
// Bootstrap opens process-owned resources. Its cleanup is called once even if
|
||||
// installation fails. On error, bootstrap must return cleanup or clean its own
|
||||
// resources before returning, never both.
|
||||
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Services []ServiceFactory
|
||||
ConfigFactory ConfigFactory
|
||||
Bootstrap RuntimeBootstrapFunc
|
||||
Finish RuntimeFinishFunc
|
||||
}
|
||||
|
||||
type ServiceFactory struct {
|
||||
Name mservice.Type
|
||||
Factory MicroServiceFactoryT
|
||||
Bootstrap Bootstrap
|
||||
Versions map[string]Installer
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) RuntimeName() string {
|
||||
if cfg.Name == "" {
|
||||
return "edge"
|
||||
return "product"
|
||||
}
|
||||
return cfg.Name
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||
if cfg.ConfigFactory != nil {
|
||||
return cfg.ConfigFactory()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package v1 composes the product's first public HTTP contract.
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
|
||||
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
|
||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func Install(scope api.Scope, deps *api.Dependencies) error {
|
||||
if deps == nil || deps.Stores == nil {
|
||||
return merrors.InvalidArgument("v1 auth dependencies are required")
|
||||
}
|
||||
flows := public.Flows{Onboarding: deps.Auth}
|
||||
if deps.Factors != nil {
|
||||
flows.Factors = deps.Factors
|
||||
}
|
||||
// Signature is resolved at bootstrap, not by each version from the environment.
|
||||
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
|
||||
if deps.Factors != nil {
|
||||
factors.Install(routes, deps.Factors, scope.Logger)
|
||||
}
|
||||
identity.Install(routes, deps.Identity, scope.Logger)
|
||||
paymenthttp.Install(routes, deps.Payments, scope.Logger)
|
||||
return nil
|
||||
}
|
||||
@@ -2,21 +2,12 @@ package httptransport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -29,33 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
mw.epdispatcher.Handler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
if mw.notifications != nil {
|
||||
_ = mw.notifications.Close()
|
||||
if mw.metrics != nil {
|
||||
mw.metrics.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
mw.logger.Debug("Installing middleware stack...")
|
||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||
@@ -107,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||
}
|
||||
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
notificationConfig ...*notifications.Config,
|
||||
) (*Middleware, error) {
|
||||
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
}
|
||||
@@ -125,55 +92,12 @@ func CreateMiddleware(
|
||||
if err := config.CORS.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p := &Middleware{
|
||||
logger: logger.Named("middleware"),
|
||||
router: apiRouter,
|
||||
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||
}
|
||||
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
||||
p.installMiddleware(config, debug)
|
||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||
var err error
|
||||
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err = authFlow.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.notifications, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow.Sender = p.notifications
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.Verification,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
authFlow,
|
||||
)
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
// Package profile selects the locally owned components of the copied BFF.
|
||||
// Package profile selects the product's supported API implementations.
|
||||
package profile
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/app/api/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
)
|
||||
|
||||
const Accounts = "accounts"
|
||||
|
||||
func Runtime() api.RuntimeConfig {
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
||||
}
|
||||
|
||||
func bootstrap(a api.API) (api.RuntimeComponents, error) {
|
||||
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.RuntimeComponents{Accounts: accounts}, nil
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
||||
}
|
||||
@@ -2,68 +2,20 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Microservices = []mservice.MicroService
|
||||
|
||||
// APIImp represents the structure of the APIImp
|
||||
type APIImp struct {
|
||||
lifecycleCtx context.Context
|
||||
logger mlogger.Logger
|
||||
db db.Factory
|
||||
domain domainprovider.DomainProvider
|
||||
config *api.Config
|
||||
profile api.ConfigProvider
|
||||
runtime api.RuntimeConfig
|
||||
components api.RuntimeComponents
|
||||
services Microservices
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
type instance struct {
|
||||
logger mlogger.Logger
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
cleanup func(context.Context) error
|
||||
once sync.Once
|
||||
finishErr error
|
||||
}
|
||||
|
||||
func (a *APIImp) LifecycleContext() context.Context {
|
||||
return a.lifecycleCtx
|
||||
}
|
||||
|
||||
func (a *APIImp) Logger() mlogger.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIImp) Config() *api.Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||
return a.profile
|
||||
}
|
||||
|
||||
func (a *APIImp) DBFactory() db.Factory {
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||
return a.domain
|
||||
}
|
||||
|
||||
func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
}
|
||||
return a.components[name]
|
||||
}
|
||||
|
||||
func (a *APIImp) Name() string {
|
||||
return "api"
|
||||
}
|
||||
func (a *instance) Name() string { return "api" }
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -16,93 +19,67 @@ import (
|
||||
)
|
||||
|
||||
func CreateAPI(
|
||||
lifecycleCtx context.Context,
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
config *api.Config,
|
||||
profileConfig api.ConfigProvider,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
rootRouter, apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
runtime api.RuntimeConfig,
|
||||
) (mservice.MicroService, error) {
|
||||
p := &APIImp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
logger: logger.Named("api"),
|
||||
config: config,
|
||||
profile: profileConfig,
|
||||
db: db,
|
||||
runtime: runtime,
|
||||
) (_ mservice.MicroService, result error) {
|
||||
if config == nil || config.Mw == nil {
|
||||
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
||||
}
|
||||
|
||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
||||
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||
return nil, err
|
||||
if runtime.Bootstrap == nil {
|
||||
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
||||
}
|
||||
|
||||
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||
if err := p.installServices(); err != nil {
|
||||
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installRuntimeBasics(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
) error {
|
||||
var err error
|
||||
if a.domain, err = domainprovider.CreateDomainProvider(
|
||||
a.logger,
|
||||
a.config.Mw.DomainEnv,
|
||||
a.config.Mw.APIProtocolEnv,
|
||||
a.config.Mw.EndPointEnv,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to initialize domain provider")
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Domain provider installed")
|
||||
|
||||
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
|
||||
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
|
||||
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
|
||||
return err
|
||||
}
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
a.config.Notifications,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||
if a.runtime.Bootstrap != nil {
|
||||
components, err := a.runtime.Bootstrap(a)
|
||||
// Resolve every URL before opening resources or publishing routes.
|
||||
scopes := make([]api.Scope, len(mounts))
|
||||
for i, mount := range mounts {
|
||||
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
a.components = components
|
||||
a.logger.Info("Runtime profile bootstrap complete",
|
||||
zap.String("runtime", a.runtime.RuntimeName()),
|
||||
zap.Int("components", len(components)))
|
||||
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
|
||||
scopes[i].Router.Use(browser.Scope(mount.Path))
|
||||
}
|
||||
return nil
|
||||
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := a.Finish(context.Background()); err != nil {
|
||||
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
|
||||
a.cleanup = cleanup
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, mount := range mounts {
|
||||
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
|
||||
}
|
||||
}
|
||||
// Attach only after every installer succeeds.
|
||||
for i, mount := range mounts {
|
||||
apiRouter.Mount(mount.Path, scopes[i].Router)
|
||||
scopes[i].Logger.Info("API version installed")
|
||||
}
|
||||
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return a, nil
|
||||
}
|
||||
@@ -2,16 +2,20 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -24,7 +28,7 @@ import (
|
||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
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)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
@@ -48,10 +52,9 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||
root.Mount("/", routes)
|
||||
@@ -62,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
result = httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||
// Enabling the adapter validates credentials, composes once, and does not
|
||||
// register any provider resource until a customer starts sign-up.
|
||||
const source = "507f1f77bcf86cd799439011"
|
||||
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
|
||||
require.NoError(t, err)
|
||||
file := filepath.Join(t.TempDir(), "credential")
|
||||
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
|
||||
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
|
||||
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dependencies.Auth.Facts)
|
||||
require.NoError(t, finish(ctx))
|
||||
example.API.Sendico.TokenFile = ""
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
example.API.Notifications = nil
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -4,28 +4,20 @@ import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) Finish(ctx context.Context) error {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
a.mw.Finish()
|
||||
var lastError error
|
||||
for i := len(a.services) - 1; i >= 0; i-- {
|
||||
if err := (a.services[i]).Finish(ctx); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing service",
|
||||
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
|
||||
} else {
|
||||
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
|
||||
func (a *instance) Finish(ctx context.Context) error {
|
||||
a.once.Do(func() {
|
||||
if a.health != nil {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
}
|
||||
}
|
||||
if a.runtime.Finish != nil {
|
||||
if err := a.runtime.Finish(a); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||
if a.cleanup != nil {
|
||||
a.finishErr = a.cleanup(ctx)
|
||||
}
|
||||
}
|
||||
return lastError
|
||||
if a.mw != nil {
|
||||
a.mw.Finish()
|
||||
}
|
||||
})
|
||||
return a.finishErr
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func validMount(value string) bool {
|
||||
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
|
||||
if !validMount(endpoint) {
|
||||
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
|
||||
}
|
||||
if configured == nil {
|
||||
configured = []api.Mount{{Version: "v1", Path: endpoint}}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, mount := range configured {
|
||||
if supported[mount.Version] == nil {
|
||||
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
if seen[mount.Version] {
|
||||
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
seen[mount.Version] = true
|
||||
if !validMount(mount.Path) {
|
||||
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
|
||||
}
|
||||
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
|
||||
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
|
||||
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
|
||||
}
|
||||
}
|
||||
for _, previous := range configured[:i] {
|
||||
if overlaps(mount.Path, previous.Path) {
|
||||
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
|
||||
}
|
||||
}
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *api.Config {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var raw struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
||||
return &raw.API
|
||||
}
|
||||
|
||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
||||
initialized, closed := 0, 0
|
||||
deps := &api.Dependencies{}
|
||||
installer := func(version string) api.Installer {
|
||||
return func(scope api.Scope, got *api.Dependencies) error {
|
||||
require.Same(t, deps, got)
|
||||
link, err := scope.URLs.GetAPILink("accounts")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
||||
})
|
||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
initialized++
|
||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
||||
}}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
||||
require.NoError(t, err)
|
||||
root.Mount("/", routes)
|
||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
require.Equal(t, expected, result.Body.String())
|
||||
}
|
||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
||||
for _, mount := range config.Versions {
|
||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
||||
result := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
||||
root.ServeHTTP(result, request)
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
cookies := result.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
||||
if method == http.MethodDelete {
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
require.Equal(t, 1, initialized)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 3)
|
||||
for range 3 {
|
||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
||||
for name, mounts := range map[string][]api.Mount{
|
||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = mounts
|
||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
t.Fatal("bootstrap must not run")
|
||||
return nil, nil, nil
|
||||
}})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
||||
closed := 0
|
||||
cause := merrors.InvalidArgument("test installation failure")
|
||||
routes := chi.NewRouter()
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
||||
},
|
||||
Versions: map[string]api.Installer{
|
||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
return nil
|
||||
},
|
||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
||||
}})
|
||||
require.True(t, errors.Is(err, cause))
|
||||
require.Equal(t, 1, closed)
|
||||
result := httptest.NewRecorder()
|
||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
}
|
||||
|
||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, nil, nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
defer srv.Finish(context.Background())
|
||||
root.Mount("/", routes)
|
||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, status, result.Code, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
closed := 0
|
||||
cause := merrors.Internal("bootstrap failure")
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
||||
}})
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
||||
cause := merrors.Internal("cleanup failure")
|
||||
closed := 0
|
||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
@@ -1,39 +1,97 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
a.services = append(a.services, srv)
|
||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||
}
|
||||
|
||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||
srv, err := srvf(a)
|
||||
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
||||
limits, err := config.Mw.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
a.installMicroservice(srv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
for _, service := range a.runtime.Services {
|
||||
if service.Factory == nil {
|
||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
|
||||
var client *notifications.Client
|
||||
cleanup := func(context.Context) error {
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
if err := a.addMicroservice(service.Factory); err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := cleanup(context.Background()); err != nil {
|
||||
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if c := config.Notifications; c != nil {
|
||||
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err := deps.Auth.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Sender = client
|
||||
if config.Sendico != nil {
|
||||
provider, err := sendico.Open(*config.Sendico)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bindings, err := identity.OpenStore(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
|
||||
deps.Auth.Facts = deps.Identity
|
||||
paymentStore, err := payments.OpenStore(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||
logger.Info("Sendico sign-up facts integration enabled")
|
||||
}
|
||||
records, err := factorstore.Open(logger, db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return nil
|
||||
if deps.Payments != nil {
|
||||
deps.Payments.Factors = deps.Factors
|
||||
}
|
||||
if config.Sendico != nil && deps.Auth == nil {
|
||||
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||
}
|
||||
logger.Info("Product dependencies initialized")
|
||||
return deps, cleanup, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, setting string
|
||||
wantNil bool
|
||||
wantLength int
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "disabled", setting: " versions: []\n"},
|
||||
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
|
||||
if err := instance.decodeConfig([]byte(configured)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := instance.config.API.SharedConfig().Versions
|
||||
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
|
||||
t.Fatalf("unexpected versions: %#v", versions)
|
||||
}
|
||||
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
|
||||
t.Fatalf("lost version configuration: %#v", versions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (i *Imp) Start() error {
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
i.db.CloseConnection()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type DomainProvider interface {
|
||||
GetAPILink(linkElem ...string) (string, error)
|
||||
}
|
||||
|
||||
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
|
||||
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
|
||||
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package domainproviderimp
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
|
||||
return url.JoinPath(dp.apiEndpoint, linkElem...)
|
||||
}
|
||||
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
|
||||
if protocol != "http" && protocol != "https" {
|
||||
return nil, merrors.InvalidArgument("API protocol must be http or https")
|
||||
}
|
||||
parsed, err := url.Parse(protocol + "://" + domain)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
|
||||
}
|
||||
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
|
||||
}
|
||||
p := new(DomainProviderImp)
|
||||
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
|
||||
var err error
|
||||
apiPath := os.Getenv(apiPointEnv)
|
||||
p.domain = protocol + "://" + domain
|
||||
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
|
||||
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package domainproviderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExplicitOriginAndMount(t *testing.T) {
|
||||
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
|
||||
require.NoError(t, err)
|
||||
link, err := provider.GetAPILink("accounts", "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
|
||||
link, err = provider.GetFullLink("signup")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/signup", link)
|
||||
}
|
||||
func TestInvalidOrigin(t *testing.T) {
|
||||
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
|
||||
require.Error(t, err, domain)
|
||||
}
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
|
||||
require.Error(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
|
||||
for _, dir := range []string{"../app/runtime", "../app/http"} {
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !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
|
||||
}
|
||||
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
|
||||
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package flow owns signup and email challenge workflows.
|
||||
// Package flow owns signup and account activation, independently of second-factor checks.
|
||||
package flow
|
||||
|
||||
import (
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,9 +25,14 @@ import (
|
||||
)
|
||||
|
||||
const Cooldown = time.Minute
|
||||
const LoginTTL = 10 * time.Minute
|
||||
|
||||
type SignupFacts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
Ready(context.Context, bson.ObjectID) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Facts SignupFacts
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
Tokens verification.DB
|
||||
@@ -49,14 +53,6 @@ func (s *Service) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func NormalizeEmail(email string) (string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
a, err := mail.ParseAddress(email)
|
||||
if err != nil || a.Address != email || len(email) > 254 {
|
||||
return "", merrors.InvalidArgument("valid email required")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
func (s *Service) key(parts ...string) string {
|
||||
h := hmac.New(sha256.New, s.Secret)
|
||||
for _, p := range parts {
|
||||
@@ -83,7 +79,7 @@ func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
||||
email, err := NormalizeEmail(a.Login)
|
||||
email, err := customer.NormalizeEmail(a.Login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
||||
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||
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) {
|
||||
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||
return nil, merrors.DataConflict("email already registered")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
@@ -120,12 +121,12 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
||||
}
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
||||
} else {
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||
email, err := NormalizeEmail(email)
|
||||
email, err := customer.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -142,56 +143,41 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil
|
||||
}
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Issue(ctx context.Context, a *customer.Account, purpose verificationmodel.VerificationPurpose, scope, key string, deadlines ...time.Time) (time.Time, error) {
|
||||
func (s *Service) activationKey(email, key string) string {
|
||||
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
||||
}
|
||||
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 (maximum 128 characters)")
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
secretKey := s.key(a.ID.Hex(), string(purpose), scope, key)
|
||||
req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5)
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
req = verification.NewLinkRequest(a.ID, purpose, "").WithTTL(24 * time.Hour)
|
||||
}
|
||||
if len(deadlines) > 0 {
|
||||
remaining := time.Until(deadlines[0])
|
||||
if remaining <= 0 {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
if remaining < req.Ttl {
|
||||
req.WithTTL(remaining)
|
||||
}
|
||||
}
|
||||
req.WithContextRef(scope).WithIdempotencyKey(secretKey).WithCooldown(Cooldown)
|
||||
now := time.Now().UTC()
|
||||
// Destination limits span every challenge/context, including password retries.
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, now))
|
||||
secretKey := s.activationKey(a.Login, 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(now) {
|
||||
if !result.ExpiresAt.After(time.Now()) {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
message := notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.login", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"code": result.VerificationToken}}
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", result.VerificationToken)
|
||||
u.RawQuery = q.Encode()
|
||||
message.Template = "auth.activation"
|
||||
message.Parameters = map[string]string{"url": u.String()}
|
||||
}
|
||||
// Replay recovers ambiguous RPC failures using the same rendered payload and expiry.
|
||||
if err = s.Sender.Send(ctx, message); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return result.ExpiresAt, nil
|
||||
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 {
|
||||
@@ -209,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil, merrors.Unauthorized("account cannot be activated")
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||
})
|
||||
return err
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
model "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -39,26 +38,26 @@ 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))}
|
||||
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.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
_, 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, "pending-scope", tokens.request.ContextRef)
|
||||
require.LessOrEqual(t, tokens.request.Ttl, time.Minute)
|
||||
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
|
||||
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
|
||||
sender.err = nil
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
_, err = service.sendActivation(context.Background(), a, "client-key")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.key(a.ID.Hex(), string(model.PurposeLogin), "pending-scope", "client-key"))
|
||||
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "expired")
|
||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
require.Len(t, sender.messages, 2)
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
accounts.customer.Status = status
|
||||
routes := chi.NewRouter()
|
||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
||||
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
+1
-1
@@ -6,9 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
|
||||
service mservice.Type
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||
router.Use(jwtauth.Verifier(ja))
|
||||
router.Use(jwtauth.Authenticator(ja))
|
||||
File renamed without changes.
+10
-15
@@ -1,16 +1,12 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
@@ -36,29 +32,28 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
|
||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewDispatcher(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
endpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
token *middleware.TokenConfig,
|
||||
signature middleware.Signature,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
|
||||
return path.Join(er.apiEndpoint, service, handler)
|
||||
return path.Join("/", er.apiEndpoint, service, handler)
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
+1
-1
@@ -5,8 +5,8 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
|
||||
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
|
||||
require.Equal(t, "signup_attention_required", envelope.Data.Error)
|
||||
require.NotContains(t, response.Body.String(), "private upstream detail")
|
||||
}
|
||||
+3
-3
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
|
||||
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -225,7 +225,7 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||
return h
|
||||
}
|
||||
if a.flow != nil {
|
||||
if a.factors != nil {
|
||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||
}
|
||||
return a.respondPendingLogin(account)
|
||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+2
-3
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -183,11 +183,10 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
PrivateKey: []byte("test-secret"),
|
||||
}
|
||||
|
||||
router := NewRouter(
|
||||
router := Install(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
+1
-2
@@ -47,11 +47,10 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||
}
|
||||
return NewRouter(
|
||||
return Install(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
+12
-4
@@ -6,7 +6,7 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
session *sessionmodel.SessionIdentifier,
|
||||
account *customermodel.Account,
|
||||
) http.HandlerFunc {
|
||||
result, err := pr.prepareLogin(ctx, r, session, account)
|
||||
if err != nil {
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
|
||||
refreshToken, err := rtokens.PrepareRefreshToken(
|
||||
ctx,
|
||||
r,
|
||||
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
return nil, err
|
||||
}
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := sresponse.TokenData{
|
||||
Token: refreshToken.RefreshToken,
|
||||
Expiration: refreshToken.ExpiresAt,
|
||||
}
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+18
-8
@@ -5,11 +5,11 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
@@ -17,7 +17,13 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Flows struct {
|
||||
Onboarding *flow.Service
|
||||
Factors twofactor.API
|
||||
}
|
||||
|
||||
type PublicRouter struct {
|
||||
factors twofactor.API
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
@@ -35,18 +41,17 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
|
||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
router chi.Router,
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
flows ...Flows,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
@@ -62,11 +67,16 @@ func NewRouter(
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
if len(flows) > 0 && flows[0] != nil {
|
||||
hr.flow = flows[0]
|
||||
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))
|
||||
}
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
+40
-19
@@ -9,20 +9,18 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"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"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -35,12 +33,15 @@ type signupRequest struct {
|
||||
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"`
|
||||
@@ -71,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrFailedPrecondition):
|
||||
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
|
||||
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):
|
||||
@@ -161,19 +164,28 @@ func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, session
|
||||
return pr.flowError(err)
|
||||
}
|
||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
||||
return pr.queueLogin(r, a, token, key)
|
||||
return pr.queueLogin(r, a, token, key, "")
|
||||
}
|
||||
func randomKey() string { return rand.Text() }
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc {
|
||||
expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration)
|
||||
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)
|
||||
}
|
||||
if token.Expiration.Before(expires) {
|
||||
expires = token.Expiration
|
||||
}
|
||||
pr.logger.Info("Login verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, challengeResponse{UserID: a.ID.Hex(), PendingToken: token, Target: mask.Email(a.Login), ExpiresAt: expires, ResendAt: time.Now().Add(flow.Cooldown)})
|
||||
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 {
|
||||
@@ -206,11 +218,14 @@ func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
||||
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)
|
||||
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
|
||||
@@ -228,13 +243,19 @@ func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||
return h
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if len(code) != 6 {
|
||||
return pr.flowError(verification.ErorrTokenNotFound())
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil {
|
||||
scope := loginScope(req.PendingToken)
|
||||
var result http.HandlerFunc
|
||||
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
|
||||
var err error
|
||||
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex()))
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a)
|
||||
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
||||
return result
|
||||
}
|
||||
+75
-6
@@ -15,14 +15,19 @@ import (
|
||||
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/identity"
|
||||
"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.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -31,6 +36,38 @@ type capturedMail struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
type signupProvider struct {
|
||||
registrations, submissions int
|
||||
fail bool
|
||||
last identity.Facts
|
||||
}
|
||||
|
||||
func (*signupProvider) Namespace() string { return "test-provider" }
|
||||
func (p *signupProvider) Register(context.Context, string) (string, error) {
|
||||
p.registrations++
|
||||
return "subject", nil
|
||||
}
|
||||
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||
p.submissions++
|
||||
p.last = facts
|
||||
if p.fail {
|
||||
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
|
||||
}
|
||||
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type activationGuard struct {
|
||||
flow.SignupFacts
|
||||
block bool
|
||||
}
|
||||
|
||||
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||
if g.block {
|
||||
return merrors.ServiceUnavailable("intake receipt unavailable")
|
||||
}
|
||||
return g.SignupFacts.Ready(ctx, id)
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
@@ -41,7 +78,7 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
|
||||
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)
|
||||
@@ -61,10 +98,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
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())
|
||||
bindings, err := identity.OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
provider := &signupProvider{fail: true}
|
||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||
// 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, stores.Verification, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, service)
|
||||
Install(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)
|
||||
@@ -76,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
}
|
||||
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, 503, w.Code, w.Body.String())
|
||||
require.Empty(t, mail.messages)
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
|
||||
require.Empty(t, mail.messages)
|
||||
// Only the original password can resume a pending local customer.
|
||||
signup["password"] = "different password"
|
||||
require.Equal(t, 400, post("signup", signup).Code)
|
||||
signup["password"] = " correct password "
|
||||
signup["name"] = "Changed retry name"
|
||||
provider.fail = false
|
||||
w = post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, "Customer", provider.last.FullName)
|
||||
require.Equal(t, "user@example.com", provider.last.Email)
|
||||
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)
|
||||
@@ -88,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||
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)
|
||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||
service.Facts = guard
|
||||
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
guard.block = false
|
||||
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)
|
||||
@@ -113,14 +182,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": wrong}).Code)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code})
|
||||
require.Equal(t, 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, "code": code}).Code)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
@@ -9,6 +9,17 @@ database:
|
||||
uri_env: SXCHNGE_MONGO_URI
|
||||
database: sxchnge
|
||||
api:
|
||||
# Optional public Sendico facts integration. Enable with provisioned values;
|
||||
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
|
||||
# sendico:
|
||||
# api_url: https://sendico.example/api/v1
|
||||
# organization_ref: <sxchnge-organization-object-id>
|
||||
# source_account_ref: <integration-account-object-id>
|
||||
# token_file: /run/sxchnge/sendico-token
|
||||
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||
# versions:
|
||||
# - version: v1
|
||||
# path: /api/v1
|
||||
middleware:
|
||||
domain_env: SXCHNGE_DOMAIN
|
||||
api_endpoint_env: SXCHNGE_API_ENDPOINT
|
||||
@@ -32,8 +43,5 @@ api:
|
||||
check:
|
||||
min_length: 12
|
||||
notifications:
|
||||
address: localhost:8443
|
||||
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
|
||||
ca_file: ../notifications/tls/ca.pem
|
||||
server_name: localhost
|
||||
address: localhost:8083
|
||||
activation_url: http://localhost:5126/verify-email
|
||||
@@ -2,6 +2,10 @@ package accountserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
@@ -15,9 +19,6 @@ import (
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func customer(login string) *customermodel.Account {
|
||||
@@ -29,7 +30,7 @@ func customer(login string) *customermodel.Account {
|
||||
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
|
||||
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)
|
||||
@@ -86,7 +87,10 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
|
||||
deps, err := edgemongo.ResolveDependencies(factory)
|
||||
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)
|
||||
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
|
||||
require.NotContains(t, collections, forbidden)
|
||||
@@ -116,7 +120,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||
require.ErrorIs(t, err, outerFailure)
|
||||
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
|
||||
count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, count)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (string, error) {
|
||||
return s.createAccount(ctx, acct, "")
|
||||
}
|
||||
|
||||
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||
if len(key) < 32 || len(key) > 128 {
|
||||
return "", merrors.InvalidArgument("server activation key required")
|
||||
}
|
||||
return s.createAccount(ctx, acct, key)
|
||||
}
|
||||
|
||||
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||
if acct == nil {
|
||||
return "", merrors.InvalidArgument("account is required")
|
||||
}
|
||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.VerifyAccount(tx, saved)
|
||||
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
@@ -243,6 +254,13 @@ func NewAccountService(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &service{logger: logger, config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
|
||||
return FromStores(l, config, stores, dbf.TransactionFactory())
|
||||
}
|
||||
|
||||
func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) {
|
||||
if config == nil || stores == nil || transactions == nil {
|
||||
return nil, merrors.InvalidArgument("customer dependencies are required")
|
||||
}
|
||||
return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package accountdb
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"strings"
|
||||
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -16,11 +16,11 @@ type AccountDB struct {
|
||||
template.DBImp[*customermodel.Account]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) {
|
||||
func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
|
||||
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 != "" {
|
||||
collection = normalized
|
||||
} else {
|
||||
|
||||
@@ -2,11 +2,13 @@ package accountservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -34,6 +36,9 @@ type AccountService interface {
|
||||
ctx context.Context,
|
||||
acct *customermodel.Account,
|
||||
) (verificationToken string, err error)
|
||||
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
|
||||
// service replay an unsent initial token. The key must be server-secret-derived.
|
||||
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
|
||||
|
||||
VerifyAccount(
|
||||
ctx context.Context,
|
||||
@@ -61,3 +66,8 @@ func NewAccountService(
|
||||
) (AccountService, error) {
|
||||
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
||||
}
|
||||
|
||||
// FromStores composes a customer service using already initialized storage.
|
||||
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
|
||||
return accountserviceimp.FromStores(logger, config, stores, transactions)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package identityhttp
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/product/identity"
|
||||
|
||||
type profileDTO struct {
|
||||
FullName string `json:"fullName"`
|
||||
DateOfBirth string `json:"dateOfBirth"`
|
||||
ResidenceCountry string `json:"residenceCountry"`
|
||||
}
|
||||
|
||||
func (p profileDTO) model() identity.Profile {
|
||||
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
|
||||
}
|
||||
|
||||
type verificationDTO struct {
|
||||
Status string `json:"status"`
|
||||
UpgradePending bool `json:"upgradePending"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SubmissionID string `json:"submissionId,omitempty"`
|
||||
Profile profileDTO `json:"profile"`
|
||||
}
|
||||
|
||||
func mapVerification(v identity.Verification) verificationDTO {
|
||||
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
|
||||
}
|
||||
|
||||
type limitDTO struct {
|
||||
Operation string `json:"operation"`
|
||||
Period string `json:"period"`
|
||||
Currency string `json:"currency"`
|
||||
Cap string `json:"cap"`
|
||||
Remaining string `json:"remaining"`
|
||||
Used string `json:"used"`
|
||||
Reserved string `json:"reserved"`
|
||||
}
|
||||
type limitsDTO struct {
|
||||
Tier string `json:"tier"`
|
||||
ProfileRef string `json:"profileRef"`
|
||||
Entries []limitDTO `json:"entries"`
|
||||
}
|
||||
|
||||
func mapLimits(v identity.Limits) limitsDTO {
|
||||
entries := make([]limitDTO, 0, len(v.Entries))
|
||||
for _, r := range v.Entries {
|
||||
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
|
||||
}
|
||||
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package identityhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
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 *identity.Service
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
|
||||
h := &router{service, logger.Named("identity_http")}
|
||||
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
|
||||
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
|
||||
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
|
||||
}
|
||||
|
||||
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
v, err := h.service.Limits(r.Context(), a)
|
||||
return h.result(mapLimits(v), err)
|
||||
}
|
||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||
if err == nil {
|
||||
return response.Ok(h.logger, data)
|
||||
}
|
||||
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
|
||||
case errors.Is(err, merrors.ErrAccessDenied):
|
||||
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
|
||||
default:
|
||||
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
|
||||
}
|
||||
}
|
||||
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
v, err := h.service.Verification(r.Context(), a)
|
||||
return h.result(mapVerification(v), err)
|
||||
}
|
||||
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
// Read multipart as a bounded stream. Never spill identity documents to disk.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("multipart required"))
|
||||
}
|
||||
fields := map[string][]byte{}
|
||||
for {
|
||||
part, readErr := reader.NextPart()
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
|
||||
}
|
||||
name := part.FormName()
|
||||
limit := int64(8192)
|
||||
switch name {
|
||||
case "document", "selfie":
|
||||
limit = identity.MaxPhotoBytes
|
||||
case "profile", "idempotencyKey":
|
||||
default:
|
||||
_ = part.Close()
|
||||
return h.result(nil, merrors.InvalidArgument("unexpected field"))
|
||||
}
|
||||
if _, exists := fields[name]; exists {
|
||||
_ = part.Close()
|
||||
return h.result(nil, merrors.InvalidArgument("duplicate field"))
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
|
||||
_ = part.Close()
|
||||
if readErr != nil || int64(len(data)) > limit {
|
||||
return h.result(nil, merrors.InvalidArgument("field too large"))
|
||||
}
|
||||
fields[name] = data
|
||||
}
|
||||
var p profileDTO
|
||||
if err = json.Unmarshal(fields["profile"], &p); err != nil {
|
||||
return h.result(nil, merrors.InvalidArgument("profile required"))
|
||||
}
|
||||
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
|
||||
return h.result(mapVerification(v), err)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package identityhttp
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
|
||||
h := router{logger: zap.NewNop()}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/accounts/identity", nil)
|
||||
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
|
||||
require.Equal(t, 429, w.Code)
|
||||
require.Equal(t, "120", w.Header().Get("Retry-After"))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
|
||||
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
|
||||
type Facts struct {
|
||||
FullName string `bson:"fullName"`
|
||||
Email string `bson:"email"`
|
||||
CustomerRef string `bson:"customerRef"`
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
SubmissionRef string `bson:"submissionRef"`
|
||||
SubjectRef string `bson:"subjectRef"`
|
||||
AcceptedAt time.Time `bson:"acceptedAt"`
|
||||
}
|
||||
|
||||
// Binding uses the local customer ID as its primary key. Namespace is an opaque
|
||||
// adapter configuration fingerprint; no platform tenancy enters the customer model.
|
||||
type Binding struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Namespace string `bson:"namespace"`
|
||||
RegistrationRequest string `bson:"registrationRequest"`
|
||||
Facts Facts `bson:"facts"`
|
||||
Registering bool `bson:"registering"`
|
||||
RegisteringAt time.Time `bson:"registeringAt"`
|
||||
SubjectRef string `bson:"subjectRef"`
|
||||
Receipt *Receipt `bson:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
func (*Binding) Collection() string { return "signup_facts" }
|
||||
|
||||
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
|
||||
@@ -0,0 +1,31 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
const MaxPhotoBytes = 10 << 20
|
||||
|
||||
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
|
||||
// memory before decoding pixels. File names and client MIME types are ignored.
|
||||
func ValidatePhoto(data []byte) error {
|
||||
if len(data) == 0 || len(data) > MaxPhotoBytes {
|
||||
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || (format != "jpeg" && format != "png") {
|
||||
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||
}
|
||||
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
|
||||
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
|
||||
}
|
||||
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
|
||||
return merrors.InvalidArgument("readable JPEG or PNG required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Namespace() string
|
||||
Register(context.Context, string) (string, error)
|
||||
Submit(context.Context, string, string, Facts) (Receipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Provider Provider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
|
||||
|
||||
// Ensure completes only after a provider receipt has been persisted. Registration
|
||||
// has no idempotency contract, so its durable claim is never automatically expired.
|
||||
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
|
||||
if a == nil || a.ID.IsZero() {
|
||||
return merrors.InvalidArgument("customer required")
|
||||
}
|
||||
started := time.Now()
|
||||
requestRef := ""
|
||||
outcome := "unavailable"
|
||||
defer func() {
|
||||
requests.WithLabelValues(outcome).Inc()
|
||||
duration.Observe(time.Since(started).Seconds())
|
||||
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
|
||||
if result != nil {
|
||||
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
|
||||
} else {
|
||||
s.Logger.Info("Sign-up facts handoff completed", fields...)
|
||||
}
|
||||
}()
|
||||
namespace := s.Provider.Namespace()
|
||||
b, err := s.Store.Get(ctx, a.ID)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
|
||||
b.SetID(a.ID)
|
||||
if err = s.Store.Create(ctx, b); err != nil {
|
||||
if !errors.Is(err, merrors.ErrDataConflict) {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
|
||||
}
|
||||
b, err = s.Store.Get(ctx, a.ID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
|
||||
}
|
||||
requestRef = b.RegistrationRequest
|
||||
if b.Namespace != namespace {
|
||||
outcome = "attention_required"
|
||||
return merrors.FailedPrecondition("sign-up provider binding changed")
|
||||
}
|
||||
if b.Receipt != nil {
|
||||
outcome = "replayed"
|
||||
return nil
|
||||
}
|
||||
if b.SubjectRef == "" {
|
||||
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
|
||||
}
|
||||
if !claimed {
|
||||
// Another request may have completed while this request was claiming.
|
||||
b, err = s.Store.Get(ctx, a.ID)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
|
||||
}
|
||||
if b.SubjectRef == "" {
|
||||
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
|
||||
return unavailable()
|
||||
}
|
||||
outcome = "attention_required"
|
||||
return merrors.FailedPrecondition("individual registration requires reconciliation")
|
||||
}
|
||||
} else {
|
||||
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
|
||||
if err != nil {
|
||||
outcome = "attention_required"
|
||||
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
|
||||
}
|
||||
// Persist even if the browser left while Sendico accepted registration.
|
||||
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
|
||||
cancel()
|
||||
if err != nil {
|
||||
outcome = "attention_required"
|
||||
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
|
||||
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
|
||||
}
|
||||
b.SubjectRef = subject
|
||||
}
|
||||
}
|
||||
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
|
||||
}
|
||||
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
|
||||
return unavailable()
|
||||
}
|
||||
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
|
||||
}
|
||||
outcome = "accepted"
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ready checks confirmed intake without external I/O inside activation transactions.
|
||||
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||
b, err := s.Store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
|
||||
}
|
||||
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[bson.ObjectID]Binding
|
||||
bindErr, acceptErr error
|
||||
}
|
||||
|
||||
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.rows[id]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("missing")
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.rows[b.ID]; ok {
|
||||
return merrors.DataConflict("duplicate")
|
||||
}
|
||||
s.rows[b.ID] = *b
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b := s.rows[id]
|
||||
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
|
||||
return false, nil
|
||||
}
|
||||
b.Registering = true
|
||||
b.RegisteringAt = time.Now()
|
||||
s.rows[id] = b
|
||||
return true, nil
|
||||
}
|
||||
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.bindErr != nil {
|
||||
return s.bindErr
|
||||
}
|
||||
b := s.rows[id]
|
||||
b.SubjectRef = subject
|
||||
b.Registering = false
|
||||
s.rows[id] = b
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.acceptErr != nil {
|
||||
return s.acceptErr
|
||||
}
|
||||
b := s.rows[id]
|
||||
b.Receipt = &r
|
||||
s.rows[id] = b
|
||||
return nil
|
||||
}
|
||||
|
||||
type providerStub struct {
|
||||
registrations, submissions int
|
||||
registerErr, submitErr error
|
||||
snapshots []Facts
|
||||
sources []string
|
||||
entered, release chan struct{}
|
||||
}
|
||||
|
||||
func (*providerStub) Namespace() string { return "provider-1" }
|
||||
func (p *providerStub) Register(context.Context, string) (string, error) {
|
||||
p.registrations++
|
||||
if p.entered != nil {
|
||||
close(p.entered)
|
||||
<-p.release
|
||||
}
|
||||
return "subject-1", p.registerErr
|
||||
}
|
||||
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
|
||||
p.submissions++
|
||||
p.snapshots = append(p.snapshots, f)
|
||||
p.sources = append(p.sources, source)
|
||||
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
|
||||
}
|
||||
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
|
||||
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
|
||||
provider := &providerStub{}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Name = "Ada Lovelace"
|
||||
a.Login = "ada@example.com"
|
||||
a.Password = "never transmit"
|
||||
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
|
||||
}
|
||||
|
||||
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
p.submitErr = merrors.ServiceUnavailable("upstream")
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
b, err := store.Get(context.Background(), a.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subject-1", b.SubjectRef)
|
||||
require.Nil(t, b.Receipt)
|
||||
a.Name = "changed retry"
|
||||
p.submitErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.NoError(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Equal(t, 2, p.submissions)
|
||||
require.Equal(t, p.snapshots[0], p.snapshots[1])
|
||||
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
|
||||
require.Equal(t, p.sources[0], p.sources[1])
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 2, p.submissions)
|
||||
other := a.Copy()
|
||||
other.ID = bson.NewObjectID()
|
||||
require.Error(t, s.Ready(context.Background(), other.ID))
|
||||
}
|
||||
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
store.acceptErr = merrors.ServiceUnavailable("storage")
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
store.acceptErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Equal(t, 2, p.submissions)
|
||||
require.Equal(t, p.sources[0], p.sources[1])
|
||||
}
|
||||
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
|
||||
for _, bindFailure := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
if bindFailure {
|
||||
store.bindErr = merrors.ServiceUnavailable("storage")
|
||||
} else {
|
||||
p.registerErr = merrors.ServiceUnavailable("lost response")
|
||||
}
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
b := store.rows[a.ID]
|
||||
b.RegisteringAt = time.Now().Add(-time.Minute)
|
||||
store.rows[a.ID] = b
|
||||
store.bindErr = nil
|
||||
p.registerErr = nil
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.Zero(t, p.submissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
|
||||
s, _, p, a := fixture()
|
||||
p.entered = make(chan struct{})
|
||||
p.release = make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Ensure(context.Background(), a) }()
|
||||
<-p.entered
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
|
||||
close(p.release)
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, 1, p.registrations)
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Equal(t, 1, p.submissions)
|
||||
}
|
||||
func TestNamespaceChangeFailsClosed(t *testing.T) {
|
||||
s, store, p, a := fixture()
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
b := store.rows[a.ID]
|
||||
b.Namespace = "other-account-or-organization"
|
||||
store.rows[a.ID] = b
|
||||
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
|
||||
require.Error(t, s.Ready(context.Background(), a.ID))
|
||||
require.Equal(t, 1, p.submissions)
|
||||
}
|
||||
|
||||
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
|
||||
s, _, p, a := fixture()
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
s.Logger = zap.New(core)
|
||||
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
|
||||
require.Error(t, s.Ensure(context.Background(), a))
|
||||
p.submitErr = nil
|
||||
require.NoError(t, s.Ensure(context.Background(), a))
|
||||
require.Len(t, logs.All(), 2)
|
||||
for _, entry := range logs.All() {
|
||||
require.NotContains(t, entry.Message, a.Login)
|
||||
require.NotContains(t, entry.Message, a.Name)
|
||||
fields := entry.ContextMap()
|
||||
require.Equal(t, a.ID.Hex(), fields["account_ref"])
|
||||
require.NotEmpty(t, fields["request_ref"])
|
||||
for _, value := range fields {
|
||||
require.NotEqual(t, a.Login, value)
|
||||
require.NotEqual(t, a.Name, value)
|
||||
require.NotEqual(t, a.Password, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Get(context.Context, bson.ObjectID) (*Binding, error)
|
||||
Create(context.Context, *Binding) error
|
||||
Claim(context.Context, bson.ObjectID, string) (bool, error)
|
||||
Bind(context.Context, bson.ObjectID, string, string) error
|
||||
Accept(context.Context, bson.ObjectID, string, Receipt) error
|
||||
}
|
||||
|
||||
type mongoStore struct{ records repository.Repository }
|
||||
|
||||
func OpenStore(factory db.Factory) (Store, error) {
|
||||
d, err := deps.ResolveDependencies(factory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
|
||||
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mongoStore{records: records}, nil
|
||||
}
|
||||
|
||||
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
|
||||
b := &Binding{}
|
||||
err := s.records.Get(ctx, id, b)
|
||||
return b, err
|
||||
}
|
||||
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
|
||||
return s.records.Insert(ctx, b, nil)
|
||||
}
|
||||
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
|
||||
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
|
||||
}
|
||||
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
|
||||
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
|
||||
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
|
||||
}
|
||||
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
|
||||
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
|
||||
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
|
||||
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
|
||||
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
return unavailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestDurableBindingClaimsAndIsolation(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")
|
||||
// The handoff store must override unsafe caller defaults before claiming
|
||||
// ownership of a non-idempotent external operation.
|
||||
q.Set("w", "0")
|
||||
q.Set("readPreference", "secondary")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
store, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
|
||||
b.SetID(bson.NewObjectID())
|
||||
require.NoError(t, store.Create(ctx, b))
|
||||
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
|
||||
var wg sync.WaitGroup
|
||||
var wins atomic.Int32
|
||||
for range 12 {
|
||||
wg.Go(func() {
|
||||
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
|
||||
require.NoError(t, err)
|
||||
if claimed {
|
||||
wins.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
require.EqualValues(t, 1, wins.Load())
|
||||
stored, err := store.Get(ctx, b.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, stored.Registering)
|
||||
require.False(t, stored.RegisteringAt.IsZero())
|
||||
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
|
||||
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
|
||||
duplicate := &Binding{Namespace: b.Namespace}
|
||||
duplicate.SetID(bson.NewObjectID())
|
||||
require.NoError(t, store.Create(ctx, duplicate))
|
||||
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
|
||||
require.NoError(t, err)
|
||||
require.True(t, claimedDuplicate)
|
||||
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
|
||||
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
|
||||
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
|
||||
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||
receipt.SubjectRef = "subject"
|
||||
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
|
||||
// A separately opened store sees the durable receipt and cannot reclaim registration.
|
||||
reopened, err := OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
stored, err = reopened.Get(ctx, b.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, receipt, *stored.Receipt)
|
||||
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
|
||||
require.NoError(t, err)
|
||||
require.False(t, claimed)
|
||||
_, err = reopened.Get(ctx, bson.NewObjectID())
|
||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||
}
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user