64 Commits
Author SHA1 Message Date
tech de449d53bf Merge pull request 'Restore identity photo previews in Flutter Web' (#71) from kyc-69 into main 2026-10-23 10:51:08 +00:00
Stephan D fe60b7ac1a Record final photo preview contribution review (#69) 2026-10-23 12:50:43 +02:00
Stephan D b644a10663 Merge remote-tracking branch 'origin/main' into kyc-69 2026-10-23 12:46:56 +02:00
Stephan D c106706615 Record photo preview delivery PR (#69) 2026-10-23 12:33:52 +02:00
Stephan D 915b8e8123 Restore web identity photo previews (#69) 2026-10-23 12:33:28 +02:00
tech ec4115d083 Merge pull request 'Use dedicated day, month and year controls for birthdays' (#70) from kyc-67 into main 2026-10-23 10:32:05 +00:00
tech f3ff7fc016 Merge pull request 'Explain missing currency account setup' (#68) from wallet-66 into main 2026-10-23 10:31:58 +00:00
tech 658d715c8a Merge pull request 'Show accepted signup delivery without a second send step' (#63) from email-verification-62 into main 2026-10-23 10:31:43 +00:00
Stephan D 288a2f0c02 Record birthday control delivery PR (#67) 2026-10-23 12:20:01 +02:00
Stephan D 4605a197ad Use dedicated birthday entry controls (#67) 2026-10-23 12:19:24 +02:00
Stephan D 4b22f6ce12 Record empty-account message delivery PR (#66) 2026-10-23 12:13:40 +02:00
Stephan D b5c4772168 Explain missing currency account setup (#66) 2026-10-23 12:13:10 +02:00
tech 464bd2d565 Merge pull request 'Select residence country by searchable name' (#65) from kyc-64 into main 2026-10-23 10:12:13 +00:00
Stephan D f1ff40d072 Record country selector delivery PR (#64) 2026-10-23 11:48:34 +02:00
Stephan D 417e6d96b6 Select residence country by searchable name (#64) 2026-10-23 11:47:59 +02:00
Stephan D c8ef9c3d14 Record delivery PR for accepted signup state (#62) 2026-10-23 09:35:28 +02:00
Stephan D 3af60f38b1 Show accepted signup delivery without a second send step (#62) 2026-10-23 09:34:30 +02:00
tech e84d2d1ad3 Merge pull request 'Restore signup, email delivery and local recovery' (#59) from identity-facts-57 into main
Reviewed-on: #59
2026-10-20 18:00:23 +00:00
tech fa7b75d1b8 Merge pull request 'Complete verification links and prefill login with the verified email' (#61) from email-verification-60 into main
Reviewed-on: #61
2026-10-20 18:00:05 +00:00
Stephan D a587cbbc24 Record verification validation and local deployment (#60) 2026-10-19 14:54:53 +02:00
Stephan D 5f3b443e7b Complete verification links and preserve verified login email (#60) 2026-10-19 14:34:00 +02:00
tech 30f06c2f8f Merge pull request 'Enable Safari credential autofill for signup and login' (#58) from auth-56 into main 2026-10-19 11:02:42 +00:00
Stephan D 4c4b639321 Record private local signup request outcomes (#57) 2026-10-19 12:41:26 +02:00
Stephan D 13cc9c302e Recover local Sendico session after BFF network restarts (#57) 2026-10-19 12:34:37 +02:00
Stephan D 021bd04324 Reap orphaned frontend HTTPS probe processes (#57) 2026-10-18 13:54:30 +02:00
Stephan D 4844fb5bc9 Record verified local API recovery after Docker restart (#57) 2026-10-17 23:41:41 +02:00
Stephan D 3aeea9aa77 Record healthy SMTP deployment and passing validation (#57) 2026-10-17 23:38:10 +02:00
Stephan D 69fba8df70 Record SMTP deployment blocker and remaining restart (#57) 2026-10-17 21:17:39 +02:00
Stephan D b36eb72c23 Use Sendico SMTP mailbox for local notifications (#57) 2026-10-17 20:51:28 +02:00
Stephan D 3e4e496037 Log redacted email provider errors and diagnose sender configuration (#57) 2026-10-17 11:39:05 +02:00
Stephan D c1d28e705e Record signup fix delivery and recovery limitation 2026-10-17 07:30:50 +02:00
Stephan D abca15f047 Fix signup registration receipts and activation worker lifetime (#57) 2026-10-17 07:30:33 +02:00
Stephan D c6ab4680fd Record autofill validation and delivery link 2026-10-17 07:24:55 +02:00
Stephan D 86b6aafe7d Enable credential autofill for signup and login (#56) 2026-10-17 07:24:27 +02:00
Stephan D bd675357bb Record payout destination delivery and validation links 2026-10-16 12:02:01 +02:00
Stephan D 24af794112 Add saved payout destination UX and service contracts (#54) 2026-10-16 12:01:33 +02:00
Stephan D a5689876ff Connect local SXCHNGE to the Sendico stand 2026-10-16 10:19:59 +02:00
Stephan D 34d8ef18d1 Record quotation and payment integration delivery links 2026-10-16 09:58:12 +02:00
Stephan D 7a6510d1a8 Connect customer quotations and payment execution to Sendico (#50) 2026-10-16 09:57:28 +02:00
Stephan D 5d67c25c8e Document reconciled Sendico review dependencies 2026-10-15 09:43:26 +02:00
Stephan D b303285b8b Record identity verification delivery links 2026-10-15 02:23:24 +02:00
Stephan D 1354551ae8 Submit customer identity proofs and show reviewed limits 2026-10-15 02:22:28 +02:00
Stephan D 7bf8b8087a Record Facts intake delivery and validation (#46) 2026-10-15 00:55:33 +02:00
Stephan D a584c8189a Connect sign-up to Sendico Facts intake (#46) 2026-10-15 00:55:19 +02:00
tech 30f0455122 Merge pull request 'Run local infrastructure and product images with Vault-managed secrets' (#43) from dev-env-42 into main
Reviewed-on: #43
2026-09-13 08:35:01 +00:00
tech 9f9525df56 Merge pull request 'Install product API versions independently with shared services' (#45) from product-api-44 into dev-env-42
Reviewed-on: #45
2026-09-13 08:34:33 +00:00
Stephan D 259f84e0a1 Record API version delivery and validation 2026-09-13 10:27:08 +02:00
Stephan D d04c8b12ab Make product API versions independently installable 2026-09-13 10:26:06 +02:00
Stephan D ce6583f454 Show service links and next steps after local startup 2026-09-13 08:40:24 +02:00
Stephan D caad7a3d5b Remove internal notification caller tokens 2026-09-12 23:59:30 +02:00
Stephan D 1fe173397f Keep email runtime configuration out of Vault 2026-09-12 23:45:58 +02:00
Stephan D 9d57a65010 Group Mongo bootstrap secrets under infrastructure ownership 2026-09-12 23:36:47 +02:00
Stephan D 9dbf0bd80c Organize local infrastructure and Vault secrets by service ownership 2026-09-12 23:30:18 +02:00
Stephan D 1b8800ee73 Replace Python development tooling with Make and containerized shell 2026-09-12 23:14:34 +02:00
Stephan D 23f7b78c3b Keep Mongo operations online across Vault credential rotation 2026-09-12 22:19:36 +02:00
Stephan D 80d6917aa7 Record local environment delivery PR (#42) 2026-09-12 21:05:39 +02:00
Stephan D 184a3be413 Build local product stack with Vault-managed secrets (#42) 2026-09-12 21:04:42 +02:00
tech 8e2ef7aa88 Merge pull request 'Separate second-factor verification and delivery from authentication' (#41) from twofactor-40 into main
Reviewed-on: #41
2026-09-12 18:38:01 +00:00
Stephan D fc33630a0a Record second-factor service delivery PR (#40) 2026-09-12 20:37:09 +02:00
Stephan D ecb63b61c6 Separate second-factor verification and delivery from auth (#40) 2026-09-12 20:36:35 +02:00
tech 12a38cd654 Merge pull request 'Connect signup and login verification to email notifications' (#39) from auth-38 into main
Reviewed-on: #39
2026-09-12 17:46:15 +00:00
Stephan D b6039493c9 docs: prohibit committing autogenerated code 2026-09-12 19:45:07 +02:00
Stephan D 1a079e00cc Integrate latest notifications delivery updates and record PR #39 2026-09-12 15:09:18 +02:00
Stephan D ad5a061164 Integrate email notifications into signup and login (#38) 2026-09-12 15:07:00 +02:00
350 changed files with 21581 additions and 4262 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.local
**/.dart_tool
**/build
**/.env*
**/node_modules
**/*.pb.go
frontend/app/lib/generated
**/.DS_Store
dist
website
**/config.yml
**/tls
**/*.pem
**/*.key
**/__pycache__
+6
View File
@@ -34,3 +34,9 @@ pnpm-debug.log*
/backend/notifications/interface/notifications/**/*.pb.go /backend/notifications/interface/notifications/**/*.pb.go
/backend/notifications/config.yml /backend/notifications/config.yml
/backend/notifications/notifications /backend/notifications/notifications
# Local container pins, Vault bootstrap material and service identities
/.local/
# Python bytecode
__pycache__/
+45
View File
@@ -8,6 +8,36 @@
- For cross-module work, apply every relevant scoped instruction file and - For cross-module work, apply every relevant scoped instruction file and
completion workflow. completion workflow.
## Autogenerated code
- Never commit autogenerated code to the repository. This applies to backend,
frontend, shared packages, and every other module, including generated
protobuf bindings, API clients, serializers, and framework-generated code.
- Commit the source definitions, generator configuration, and reproducible
generation commands instead. Generate code locally or during build and CI
workflows before any dependent validation or compilation steps.
- Keep generated outputs ignored by Git. Do not force-add ignored generated
files or copy generated code into tracked source files.
- Change the source definitions or generator inputs rather than editing
generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code.
## Local development tooling
Use Make and shell for local development orchestration. Do not introduce Python
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
## Container versions
Use only the latest stable upstream releases for infrastructure, product base
images, build stages and disposable test containers. Exclude prereleases and
vendor mainline channels when a separate stable channel exists. Resolve stable
versions when building, pull fresh manifests, and pin the resolved image digests
for that local build. Fail when stable release resolution fails; never silently
fall back to an older cached version. Keep the reproducible resolver tracked and
its generated local pins ignored by Git.
## Data and state architecture ## Data and state architecture
Preserve explicit **DTO → mapper → service → data model → provider → UI** Preserve explicit **DTO → mapper → service → data model → provider → UI**
@@ -139,3 +169,18 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
into task commits. Record scope, validation, limitations, and PR links in the into task commits. Record scope, validation, limitations, and PR links in the
task and durable repository documentation. Merge only when authorized by the task and durable repository documentation. Merge only when authorized by the
user. If work is delivered as stacked PRs, document dependencies and merge order. user. If work is delivered as stacked PRs, document dependencies and merge order.
## Local infrastructure ownership
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
configuration and shell tooling under `infra/local/`. Keep product source under
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
and bootstrap administration under `infrastructure/<component>/bootstrap`.
Do not add BFF-to-notifications caller authentication on the trusted private network.
Preserve NATS authentication.
Use Caddy for local browser HTTPS and certificate management. Treat the private
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
local TLS certificates without an explicit requirement.
Store only secret values in Vault. Driver selection, sender identities, endpoints
and other non-secret runtime settings belong in service configuration files.
+55 -4
View File
@@ -5,6 +5,7 @@ test: test-backend test-frontend
.PHONY: test-frontend .PHONY: test-frontend
test-frontend: test-frontend:
cd frontend/app && flutter gen-l10n
cd frontend/packages/sxchnge_wallet && flutter test cd frontend/packages/sxchnge_wallet && flutter test
cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_kyc && flutter test
cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_analytics && flutter test
@@ -16,10 +17,12 @@ test-frontend:
.PHONY: test-backend lint-backend run-backend .PHONY: test-backend lint-backend run-backend
test-backend: generate-notifications test-backend: generate-notifications
cd backend/shared && go test -race ./... docker pull mongo:latest
cd backend/notifications/interface && go test -race ./... docker pull nats:latest
cd backend/notifications && go test -race ./... cd backend/shared && go test -race -count=1 ./...
cd backend/product && go test -race ./... cd backend/notifications/interface && go test -race -count=1 ./...
cd backend/notifications && go test -race -count=1 ./...
cd backend/product && go test -race -count=1 ./...
lint-backend: generate-notifications lint-backend: generate-notifications
cd backend/product && go test ./architecture cd backend/product && go test ./architecture
@@ -48,3 +51,51 @@ package-notifications-interface: generate-notifications
mkdir -p dist mkdir -p dist
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
# Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
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
+31 -10
View File
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography. with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real The default preview uses dummy services. Configuring the existing backend URL
account or session is created, no email is delivered, and no analytics data is and client ID selects authenticated BFF adapters for auth, identity, limits and
transmitted by those app services. The backend now contains locally owned copies wallet quotations/payment execution. Optional Sendico configuration and dedicated
of Sendico’s BFF runtime, auth/session stack and account service. The app is not customer financial resources are required for real operations; analytics still
connected to that backend or to Sendico yet. defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## Repository structure ## 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 Flutter packages can be reused by a future mobile app; mobile runners are not
currently included. Local `.vscode/` configuration is ignored. currently included. Local `.vscode/` configuration is ignored.
## Local Docker development
```sh
make build && make up
```
This builds the product images and starts an isolated Vault, authenticated MongoDB
replica set and NATS JetStream. First startup intentionally stops after bootstrap
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
and the verified sender in `infra/local/notifications/runtime.yml`.
Then rerun `make up` to start notifications, BFF and the frontend at
[https://localhost:5126](https://localhost:5126).
All image inputs resolve to current stable releases and are pinned by digest for
the build. Services fetch secrets from Vault; Mongo application credentials come
from Vault's database plugin. See the [local development guide](docs/local-development.md)
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
## Run the consumer app ## Run the consumer app
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
@@ -54,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
repository root: repository root:
```sh ```sh
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web docker run --rm --pull always -p 127.0.0.1:5123:80 \
-v "$PWD/frontend/app/build/web:/srv:ro" caddy:alpine caddy file-server --root /srv --listen :80
``` ```
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
@@ -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. [provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root. Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The copied runtime installs local authentication transport and initializes its The runtime installs local authentication, verification delivery, identity and
account service. Signup/profile endpoints, verification delivery/completion, wallet transports. Public Sendico adapters support independent customer facts,
Flutter HTTP adapters and live Sendico operations remain to be wired. See the reviewed identity/limits and quotation/execution through the BFF. Deployment and
[backend run guide](backend/README.md) for local MongoDB configuration. 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).
+8
View File
@@ -71,3 +71,11 @@ code. Auth and verification configurations moved out of application interfaces.
All 112 unconsumed public API snapshot files and their snapshot-only check were All 112 unconsumed public API snapshot files and their snapshot-only check were
removed. Future public adapters will own just the wire types they consume. removed. Future public adapters will own just the wire types they consume.
Backoffice is a separate future executable module, not an alias for product. Backoffice is a separate future executable module, not an alias for product.
## Second-factor separation (#40)
The new `product/twofactor` domain service reuses the already copied
`product/verification/store` issuance and consumption implementation at the pinned
Sendico revision. No additional Sendico source files were copied. Coupled
verification HTTP/session/private-messaging handlers were inspected but excluded.
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
+6
View File
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
builders, dynamic-template dependency, notification events and unused producer builders, dynamic-template dependency, notification events and unused producer
were removed. Resend is a new driver using the same contract. were removed. Resend is a new driver using the same contract.
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
connections and standard MIME encoders. Its implicit-TLS connection configuration
matches Sendico's existing mail client, without importing its modules or copying
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
Shared messaging was selected from that same Sendico revision's Shared messaging was selected from that same Sendico revision's
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`, `api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`, `reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
+60 -8
View File
@@ -12,22 +12,45 @@ pending-work state atomically in one document through the shared DB wrapper.
The outbox dispatcher publishes opaque IDs using shared messaging's durable The outbox dispatcher publishes opaque IDs using shared messaging's durable
transport, adapted from Sendico. A durable NATS worker claims the notification transport, adapted from Sendico. A durable NATS worker claims the notification
with an atomic conditional patch and saves the outcome before acknowledging. with an atomic conditional patch and saves the outcome before acknowledging.
`DriverConfig` selects the email driver: `sendgrid` or `resend`. `DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
Email adapters only map rendered email models to provider wire payloads. SendGrid Email adapters only map rendered email models to provider wire payloads. SendGrid
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
subject, text and HTML, bounded timeouts and safe error classification. subject, text and HTML, bounded timeouts and safe error classification.
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
verified server certificates and context-bounded connections. It sends MIME
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
It uses Go's standard SMTP client and does not import Sendico modules or share a
mutable connection between workers. Configure it with:
```yaml
email:
driver: smtp
settings:
host: mail.example.com
port: 465
username: notifications@example.com
password_env: SMTP_PASSWORD
sender_address: notifications@example.com
sender_name: SXCHNGE
network_timeout: 10
```
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
bounds every operation. Only the password belongs in Vault. There is no plaintext
fallback or certificate-verification bypass.
## Run ## Run
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here. JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
each configured caller, and the selected email provider's API key environment variable. Configure a verified sender address and template
variable. Configure a verified sender address, TLS certificate/key and template
catalog. Paths are relative to the process working directory. catalog. Paths are relative to the process working directory.
From the root, run `make run-notifications`. The gRPC listener always uses TLS. From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
`tls_cert` and `tls_key` only when TLS is explicitly required.
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
are supported by the shared driver. Mongo and messaging both use shared are supported by the shared driver. Mongo and messaging both use shared
`DriverConfig` settings; email settings reject unknown keys. `DriverConfig` settings; email settings reject unknown keys.
@@ -46,7 +69,8 @@ Multiple service workers can safely compete for jobs through atomic claims.
## Templates and parameters ## Templates and parameters
See `templates.example.json`. Parameters use Go template map syntax such as See `templates.example.json`, including the `auth.activation` and `auth.login`
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
`{{.first_name}}`. Each revision declares the exact allowed parameter names and `{{.first_name}}`. Each revision declares the exact allowed parameter names and
locale-specific content. Missing/extra parameters, unknown revisions/locales and locale-specific content. Missing/extra parameters, unknown revisions/locales and
invalid content are rejected before acceptance. Empty locale uses the default. invalid content are rejected before acceptance. Empty locale uses the default.
@@ -74,13 +98,41 @@ remaining status and request fingerprints expire through a seven-day TTL.
- Resend receives the notification ID as its provider idempotency key. Its window - Resend receives the notification ID as its provider idempotency key. Its window
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
delivery is not promised. delivery is not promised.
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
undo acceptance. Explicit SMTP 4xx replies become retryable
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
failures retain `provider_authentication_failed`). Transport/TLS errors and a
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
A stable Message-ID is included for correlation, not an exactly-once promise.
- Changing the configured driver fails queued jobs pinned to the old driver with - Changing the configured driver fails queued jobs pinned to the old driver with
`driver_changed`. Drain queued jobs before changing sender credentials/config. `driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access, - Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included. bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message Structured logs report notification IDs, driver, state and attempt count. Provider
content, template values, addresses and provider response bodies are excluded. HTTP outcomes include `provider_status`; failures also include `provider_error`
and `provider_message` when available. The adapter reads at most 16 KiB plus an
overflow byte and extracts only the Resend `name`/`message` or first SendGrid
`errors[].message`. Diagnostic messages are bounded to 512 Unicode characters
plus an ellipsis. Credentials, idempotency keys, outgoing request string values,
email addresses, URLs, long token-like strings and numeric verification/account
values embedded in the request are redacted; controls and formatting characters
are normalized. Malformed, incomplete and oversized error
responses produce a diagnostic fallback instead of raw content. Successful
response bodies are not logged. Diagnostic fields are transient: they are neither
persisted in notifications nor exposed in gRPC responses. The existing bounded
failure codes and delivery/retry decisions remain unchanged. Transport failures
include a sanitized cause. Provider outcome logging happens before the result
write so a storage failure cannot discard the only provider diagnostic.
Message content, template values, addresses and raw provider bodies must remain
excluded from logs. Provider text is sanitized external diagnostic data, never
an instruction. Use `provider_status`, `provider_error` and `provider_message`
together: the generic `provider_authentication_failed` category combines HTTP
401 and 403, including sender/domain authorization failures. A sending-only
Resend key is valid for sending, but cannot read the provider's log API.
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
sanitized provider error/message fields, including password/auth-payload redaction.
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings. Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
## Validate and distribute ## Validate and distribute
+4 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"io" "io"
"os" "os"
"strings"
"git.sendico.io/tech/sxchnge/notifications/bus" "git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
@@ -22,7 +21,6 @@ type Config struct {
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"` Database db.Config `yaml:"database"`
Templates string `yaml:"templates"` Templates string `yaml:"templates"`
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
Email email.Config `yaml:"email"` Email email.Config `yaml:"email"`
} }
@@ -40,19 +38,14 @@ func Load(path string) (Config, error) {
if err = decoder.Decode(new(any)); err != io.EOF { if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content") return c, merrors.InvalidArgument("unexpected configuration content")
} }
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 { if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
return c, merrors.InvalidArgument("missing notification configuration") return c, merrors.InvalidArgument("missing notification configuration")
} }
if (c.TLSCert == "") != (c.TLSKey == "") {
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
}
if err = c.Queue.Validate(); err != nil { if err = c.Queue.Validate(); err != nil {
return c, err return c, err
} }
seen := map[string]bool{}
for caller, env := range c.Callers {
token := os.Getenv(env)
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
}
seen[token] = true
}
return c, nil return c, nil
} }
+27 -7
View File
@@ -3,12 +3,10 @@ package app
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestExampleConfigAndSecrets(t *testing.T) { func TestExampleConfigAndSecrets(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml") c, err := Load("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" { if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration") t.Fatal("wrong driver configuration")
} }
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
if _, err = Load("../config.example.yml"); err == nil {
t.Fatal("missing credentials accepted")
}
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
data, err := os.ReadFile("../config.example.yml") data, err := os.ReadFile("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -33,3 +26,30 @@ func TestExampleConfigAndSecrets(t *testing.T) {
t.Fatal("extra YAML document accepted") t.Fatal("extra YAML document accepted")
} }
} }
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
c, err := Load("../config.example.yml")
if err != nil {
t.Fatal(err)
}
options, err := serverOptions(c)
if err != nil || len(options) != 0 {
t.Fatal("plain gRPC must not require TLS material")
}
data, err := os.ReadFile("../config.example.yml")
if err != nil {
t.Fatal(err)
}
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("incomplete TLS configuration accepted")
}
}
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
t.Fatal("bad explicit certificate accepted")
}
}
+16 -11
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"net" "net"
"os"
"sync" "sync"
"time" "time"
@@ -33,9 +32,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
if err != nil { if err != nil {
return err return err
} }
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) options, err := serverOptions(c)
if err != nil { if err != nil {
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate") return err
} }
client, err := db.ConnectMongo(logger, &c.Database) client, err := db.ConnectMongo(logger, &c.Database)
if err != nil { if err != nil {
@@ -46,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
defer cancel() defer cancel()
_ = client.Disconnect(cleanup) _ = client.Disconnect(cleanup)
}() }()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database()) repo, err := store.New(logger, client.Database())
if err != nil { if err != nil {
return err return err
@@ -58,16 +55,12 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
} }
defer rt.Close() defer rt.Close()
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger) svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue) broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
if err != nil { if err != nil {
return err return err
} }
defer broker.Close() defer broker.Close()
tokens := map[string]string{} server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
for caller, env := range c.Callers {
tokens[caller] = os.Getenv(env)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen) listener, err := net.Listen("tcp", c.Listen)
if err != nil { if err != nil {
@@ -103,3 +96,15 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
logger.Info("Notifications service stopped") logger.Info("Notifications service stopped")
return nil return nil
} }
// An omitted certificate pair uses plaintext gRPC on the private service network.
func serverOptions(c Config) ([]grpc.ServerOption, error) {
if c.TLSCert == "" && c.TLSKey == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
}
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
}
+5 -1
View File
@@ -35,9 +35,13 @@ func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin
} }
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning} settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll} consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil { startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging") return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
} }
// NATS retains this context and unsubscribes when it ends. The worker must
// live until service shutdown, independently of the provisioning deadline.
subscription, err := transport.OpenConsumer(ctx, settings, consumer) subscription, err := transport.OpenConsumer(ctx, settings, consumer)
if err != nil { if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker") return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
@@ -10,6 +10,7 @@ import (
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable" "git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
type outbox struct{ jobs []model.Notification } type outbox struct{ jobs []model.Notification }
@@ -23,6 +24,50 @@ type publisher struct {
records []reliable.Record records []reliable.Record
} }
type consumerTransport struct {
reliable.Transport
reliable.Subscription
ctx context.Context
}
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
t.ctx = ctx
return t.Subscription, nil
}
type consumerAdmin struct {
reliable.Admin
ctx context.Context
}
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
a.ctx = ctx
return nil
}
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
transport := &consumerTransport{}
admin := &consumerAdmin{}
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
t.Fatal(err)
}
if admin.ctx.Err() != context.Canceled {
t.Fatal("provisioning context was not released")
}
if _, ok := admin.ctx.Deadline(); !ok {
t.Fatal("provisioning has no deadline")
}
if err := transport.ctx.Err(); err != nil {
t.Fatalf("subscription ended with provisioning: %v", err)
}
cancel()
if transport.ctx.Err() != context.Canceled {
t.Fatal("subscription does not follow service shutdown")
}
}
func (p *publisher) Publish(_ context.Context, r reliable.Record) error { func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
if p.fail { if p.fail {
return merrors.ServiceUnavailable("offline") return merrors.ServiceUnavailable("offline")
+1 -5
View File
@@ -1,6 +1,4 @@
listen: "127.0.0.1:8083" listen: "127.0.0.1:8083"
tls_cert: "server.crt"
tls_key: "server.key"
database: database:
driver: mongodb driver: mongodb
settings: settings:
@@ -14,10 +12,8 @@ messaging:
max_reconnects: -1 max_reconnects: -1
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email: email:
driver: resend # sendgrid or resend driver: resend # sendgrid or resend; see README for authenticated SMTP settings
settings: settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com" sender_address: "notifications@example.com"
+9
View File
@@ -19,6 +19,7 @@ type Driver string
const ( const (
SendGrid Driver = "sendgrid" SendGrid Driver = "sendgrid"
Resend Driver = "resend" Resend Driver = "resend"
SMTP Driver = "smtp"
) )
type Config = sharedmodel.DriverConfig[Driver] type Config = sharedmodel.DriverConfig[Driver]
@@ -33,6 +34,11 @@ type Outcome struct {
State model.State State model.State
Code string Code string
RetryAfter time.Duration RetryAfter time.Duration
// Provider diagnostics are sanitized operational data, not public failures.
HTTPStatus int
SMTPStatus int
ProviderError string
ProviderMessage string
} }
type Sender interface { type Sender interface {
Send(context.Context, string, model.Email) Outcome Send(context.Context, string, model.Email) Outcome
@@ -46,6 +52,9 @@ type client struct {
} }
func New(config Config) (Sender, error) { func New(config Config) (Sender, error) {
if config.Driver == SMTP {
return newSMTP(config)
}
var s Settings var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"}) decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil { if err != nil {
+97
View File
@@ -0,0 +1,97 @@
package email
import (
"encoding/json"
"regexp"
"sort"
"strings"
"unicode"
)
const maxErrorBody = 16 << 10
const maxErrorMessage = 512
var (
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
numbers = regexp.MustCompile(`[0-9]{4,}`)
)
// Only the documented human-readable error fields cross the adapter boundary.
// Full bodies, headers and request data must never reach logs or public failures.
func providerFailure(body, request []byte, secrets ...string) (string, string) {
var dto struct {
Name string `json:"name"`
Message string `json:"message"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &dto); err != nil {
return "", "Provider error details unavailable: invalid JSON response"
}
if dto.Message == "" && len(dto.Errors) > 0 {
dto.Message = dto.Errors[0].Message
}
// Providers can echo any scalar string from their request DTOs. Strip those
// values before truncation, in addition to credentials and embedded tokens.
var payload any
if json.Unmarshal(request, &payload) == nil {
for _, value := range requestStrings(payload) {
secrets = append(secrets, value)
// Rendered login/verification codes and numeric account references
// can be echoed as fragments of an otherwise longer email body.
secrets = append(secrets, numbers.FindAllString(value, -1)...)
}
}
name := safeErrorText(dto.Name, secrets)
if !errorName.MatchString(name) {
name = ""
}
message := safeErrorText(dto.Message, secrets)
if message == "" {
message = "Provider did not include an error message"
}
return name, message
}
func requestStrings(value any) []string {
var values []string
switch value := value.(type) {
case string:
values = append(values, value)
case []any:
for _, item := range value {
values = append(values, requestStrings(item)...)
}
case map[string]any:
for _, item := range value {
values = append(values, requestStrings(item)...)
}
}
return values
}
func safeErrorText(text string, secrets []string) string {
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
var replacements []string
for _, secret := range secrets {
if secret != "" {
replacements = append(replacements, secret, "[redacted]")
}
}
text = strings.NewReplacer(replacements...).Replace(text)
text = addresses.ReplaceAllString(text, "[redacted-address]")
text = urls.ReplaceAllString(text, "[redacted-url]")
text = tokens.ReplaceAllString(text, "[redacted-token]")
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
}), " ")
runes := []rune(text)
if len(runes) > maxErrorMessage {
text = string(runes[:maxErrorMessage]) + "…"
}
return text
}
@@ -0,0 +1,90 @@
package email
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func TestProviderErrorDiagnostics(t *testing.T) {
for _, tc := range []struct {
name, body, code, message string
}{
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
c := client{http: server.Client(), endpoint: server.URL}
out := c.post(context.Background(), "", struct{}{})
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
t.Fatalf("unexpected provider outcome: %+v", out)
}
})
}
}
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
key := "re_credential-secret"
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
token := strings.Repeat("a", 43)
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
if err != nil {
t.Fatal(err)
}
name, result := providerFailure(body, request, key, "job-secret")
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
t.Fatal("lost useful diagnostic", name, result)
}
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
if strings.Contains(result, forbidden) {
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
}
}
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
if err != nil {
t.Fatal(err)
}
_, result = providerFailure(body, request, key)
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
t.Fatal("invalid or unbounded diagnostic")
}
}
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
}))
defer server.Close()
c := client{http: server.Client(), endpoint: server.URL}
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
t.Fatal("success leaked provider content", out)
}
}
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
out := c.post(ctx, "job-secret", struct{}{})
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
t.Fatal("transport cause missing or unsafe", out)
}
}
+17 -8
View File
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
} }
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"} return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
} }
defer resp.Body.Close() defer resp.Body.Close()
// Discard a bounded response without logging provider-controlled content. // Never pass raw provider bodies through the outcome or log them directly.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
outcome := Outcome{HTTPStatus: resp.StatusCode}
switch { switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300: case resp.StatusCode >= 200 && resp.StatusCode < 300:
return Outcome{State: model.Accepted} outcome.State = model.Accepted
case resp.StatusCode == 429: case resp.StatusCode == 429:
delay := time.Minute delay := time.Minute
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 { if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
@@ -43,12 +44,20 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 { } else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
delay = time.Until(date) delay = time.Until(date)
} }
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay} outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
case resp.StatusCode >= 500 || resp.StatusCode == 408: case resp.StatusCode >= 500 || resp.StatusCode == 408:
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"} outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
case resp.StatusCode == 401 || resp.StatusCode == 403: case resp.StatusCode == 401 || resp.StatusCode == 403:
return Outcome{State: model.Failed, Code: "provider_authentication_failed"} outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
default: default:
return Outcome{State: model.Failed, Code: "provider_rejected"} outcome.State, outcome.Code = model.Failed, "provider_rejected"
} }
if outcome.State != model.Accepted {
if readErr != nil || len(details) > maxErrorBody {
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
} else {
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
}
}
return outcome
} }
+49
View File
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"crypto/sha256"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
return nil, merrors.InvalidArgument("invalid email content")
}
var body bytes.Buffer
parts := multipart.NewWriter(&body)
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
if part.value == "" {
continue
}
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
if err != nil {
return nil, merrors.InternalWrap(err, "create email MIME part")
}
encoded := quotedprintable.NewWriter(writer)
if _, err = encoded.Write([]byte(part.value)); err != nil {
return nil, merrors.InternalWrap(err, "encode email MIME part")
}
if err = encoded.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME part")
}
}
if err := parts.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME body")
}
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
to := (&mail.Address{Address: email.Address}).String()
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
return append([]byte(headers), body.Bytes()...), nil
}
+122
View File
@@ -0,0 +1,122 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"io"
"net"
"net/smtp"
"net/textproto"
"os"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/go-viper/mapstructure/v2"
)
type smtpSettings struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
PasswordEnv string `mapstructure:"password_env"`
SenderAddress string `mapstructure:"sender_address"`
SenderName string `mapstructure:"sender_name"`
NetworkTimeout int `mapstructure:"network_timeout"`
}
type smtpSender struct {
settings smtpSettings
password string
tls *tls.Config
}
func newSMTP(config Config) (Sender, error) {
var s smtpSettings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
}
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
return nil, merrors.InvalidArgument("invalid SMTP settings")
}
password := os.Getenv(s.PasswordEnv)
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
}
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
}
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
body, err := smtpMessage(s.settings, key, email)
if err != nil {
return Outcome{State: model.Failed, Code: "invalid_payload"}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
defer cancel()
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
if err != nil {
return s.failure(err, key, email)
}
defer conn.Close()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = conn.SetDeadline(deadline); err != nil {
return s.failure(err, key, email)
}
client, err := smtp.NewClient(conn, s.settings.Host)
if err == nil {
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
}
if err == nil {
err = client.Mail(s.settings.SenderAddress)
}
if err == nil {
err = client.Rcpt(email.Address)
}
if err != nil {
return s.failure(err, key, email)
}
writer, err := client.Data()
if err != nil {
return s.failure(err, key, email)
}
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
err = writer.Close()
}
if err != nil {
return s.failure(err, key, email)
}
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
_ = client.Quit()
return Outcome{State: model.Accepted, SMTPStatus: 250}
}
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
var reply *textproto.Error
if errors.As(err, &reply) {
out.SMTPStatus = reply.Code
if reply.Code >= 400 && reply.Code < 500 {
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
} else if reply.Code >= 500 && reply.Code < 600 {
out.State, out.Code = model.Failed, "provider_rejected"
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
out.Code = "provider_authentication_failed"
}
}
}
return out
}
+223
View File
@@ -0,0 +1,223 @@
package email
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http/httptest"
"net/mail"
"net/textproto"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
)
type smtpFixture struct {
settings smtpSettings
root *x509.CertPool
body chan []byte
auth atomic.Bool
}
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
t.Helper()
certServer := httptest.NewTLSServer(nil)
cert := certServer.TLS.Certificates[0]
root := x509.NewCertPool()
root.AddCert(certServer.Certificate())
certServer.Close()
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
host, port, _ := net.SplitHostPort(listener.Addr().String())
n, _ := strconv.Atoi(port)
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
if reject == "greeting" {
_ = conn.(*tls.Conn).Handshake()
_, _ = io.Copy(io.Discard, conn)
return
}
c := textproto.NewConn(conn)
if c.PrintfLine("220 localhost ESMTP") != nil {
return
}
for {
line, err := c.ReadLine()
if err != nil {
return
}
command := strings.SplitN(line, " ", 2)[0]
if command == reject {
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
continue
}
switch command {
case "EHLO":
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
case "AUTH":
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
f.auth.Store(line == want)
_ = c.PrintfLine("235 authenticated")
case "MAIL", "RCPT":
_ = c.PrintfLine("250 OK")
case "DATA":
_ = c.PrintfLine("354 send message")
body, err := c.ReadDotBytes()
if err != nil {
return
}
f.body <- body
if reject == "final" {
_ = c.PrintfLine("%d final rejection", code)
} else if reject == "disconnect" {
return
} else {
_ = c.PrintfLine("250 queued")
}
case "QUIT":
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
return
default:
_ = c.PrintfLine("500 unexpected command")
}
}
}()
return f
}
func (f *smtpFixture) sender() *smtpSender {
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
}
func TestSMTPAcceptanceAndMIME(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
out := sender.Send(context.Background(), "notification-id", email)
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
t.Fatal("SMTP acceptance/authentication failed", out)
}
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
if err != nil {
t.Fatal(err)
}
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
if err != nil || subject != email.Subject {
t.Fatal("wrong MIME subject", subject, err)
}
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
t.Fatal("invalid sender/message identifier")
}
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil || kind != "multipart/alternative" {
t.Fatal("wrong MIME type", kind, err)
}
parts := multipart.NewReader(msg.Body, params["boundary"])
for _, want := range []string{email.Text, email.HTML} {
part, err := parts.NextPart()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(part)
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
t.Fatal("incorrect MIME content", err)
}
}
}
func TestSMTPFailureSafety(t *testing.T) {
for _, tc := range []struct {
stage string
code int
state model.State
failure string
}{
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
{"RCPT", 550, model.Failed, "provider_rejected"},
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
} {
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
f := smtpServer(t, tc.stage, tc.code)
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
t.Fatal("incorrect SMTP classification", out)
}
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
t.Fatal("SMTP rejection leaked private data")
}
if tc.code == 451 && out.RetryAfter <= 0 {
t.Fatal("temporary rejection did not schedule retry")
}
})
}
}
func TestSMTPCertificateAndCancellation(t *testing.T) {
t.Run("untrusted certificate", func(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
sender.tls.RootCAs = x509.NewCertPool()
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
t.Fatal("untrusted TLS accepted", out)
}
})
t.Run("cancel blocked greeting", func(t *testing.T) {
f := smtpServer(t, "greeting", 0)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
started := time.Now()
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
t.Fatal("SMTP ignored cancellation", out)
}
})
}
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
t.Fatal(err)
}
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
settings := map[string]any{}
for key, item := range valid {
settings[key] = item
}
settings[field] = value
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
t.Fatalf("invalid setting %s accepted", field)
}
}
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
t.Fatal("header injection accepted")
}
}
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
}
}
+3 -3
View File
@@ -20,14 +20,14 @@ Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this `git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building. checkout, use a local replace to this directory and generate before building.
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service Calls use the trusted private network without caller authentication. The service
maps tokens to configured caller identities. Callers authorize user/destination uses the existing product storage namespace. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API. ownership and preferences before sending; this contract is not a public user API.
Parameters are string values keyed by template parameter name. The entire request Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts. original resource, even after completion or expiry. A changed request conflicts.
Keys are scoped to the authenticated caller and retained at least seven days; Keys are scoped to the product namespace and retained at least seven days;
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED. 24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1"; option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
// Internal service: authenticate callers over TLS. Send durably queues work. // Internal service on the trusted private network; no caller authentication. Send durably queues work.
service NotificationsService { service NotificationsService {
rpc Send(SendRequest) returns (SendResponse); rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse); rpc Get(GetRequest) returns (GetResponse);
} }
message SendRequest { message SendRequest {
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days. string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
string user_id = 2; // Caller resolves and authorizes recipient ownership. string user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3; string template_id = 3;
string template_version = 4; string template_version = 4;
+2 -7
View File
@@ -1,7 +1,6 @@
package service_test package service_test
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
@@ -17,10 +16,9 @@ import (
"git.sendico.io/tech/sxchnge/notifications/transport" "git.sendico.io/tech/sxchnge/notifications/transport"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
) )
// Exercise authenticated TLS transport against the real service and storage. // Exercise optional TLS transport against the real service and storage.
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient { func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
t.Helper() t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -42,7 +40,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"}))) server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
t.Cleanup(func() { _ = conn.Close() }) t.Cleanup(func() { _ = conn.Close() })
return pb.NewNotificationsServiceClient(conn) return pb.NewNotificationsServiceClient(conn)
} }
func callerContext(ctx context.Context, caller string) context.Context {
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
}
@@ -26,8 +26,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type sender struct { type sender struct {
@@ -50,7 +48,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
func TestDurableDelivery(t *testing.T) { func TestDurableDelivery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8.0") container, err := mongodb.Run(ctx, "mongo:latest")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -192,7 +190,7 @@ func TestDurableDelivery(t *testing.T) {
} }
} }
// Verify the real JetStream publisher/consumer path with an isolated broker. // Verify the real JetStream publisher/consumer path with an isolated broker.
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true}) ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
} }
client := grpcClient(t, svc) client := grpcClient(t, svc)
rpcCtx := callerContext(ctx, "product") rpcCtx := ctx
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
t.Fatal("unauthenticated request accepted", e)
}
sent, err := client.Send(rpcCtx, input) sent, err := client.Send(rpcCtx, input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
if err != nil || replay.Notification.Id != sent.Notification.Id { if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err) t.Fatal("RPC idempotency failed", err)
} }
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound { if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
t.Fatal("RPC caller isolation failed", e) t.Fatal("RPC retrieval without credentials failed", e)
} }
if err = broker.PublishDue(ctx); err != nil { if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"time" "time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap" "go.uber.org/zap"
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
var outcome email.Outcome
switch { switch {
case !n.ExpiresAt.After(now): case !n.ExpiresAt.After(now):
n.State = model.Expired n.State = model.Expired
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
deadline = n.ExpiresAt deadline = n.ExpiresAt
} }
sendCtx, cancel := context.WithDeadline(ctx, deadline) sendCtx, cancel := context.WithDeadline(ctx, deadline)
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email) outcome = s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
cancel() cancel()
n.State = outcome.State n.State = outcome.State
n.FailureCode = outcome.Code n.FailureCode = outcome.Code
@@ -62,14 +64,27 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
// Persist the result even when shutdown canceled the provider request. // Persist the result even when shutdown canceled the provider request.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel() defer cancel()
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)} fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
if outcome.HTTPStatus != 0 {
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
}
if outcome.SMTPStatus != 0 {
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
}
if outcome.ProviderError != "" {
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
}
if outcome.ProviderMessage != "" {
fields = append(fields, zap.String("provider_message", outcome.ProviderMessage))
}
// Keep the provider outcome observable even if its local result write fails.
if n.State == model.Accepted { if n.State == model.Accepted {
s.logger.Info("Email accepted by provider", fields...) s.logger.Info("Email accepted by provider", fields...)
} else { } else {
s.logger.Warn("Email delivery attempt finished", fields...) s.logger.Warn("Email delivery attempt finished", fields...)
} }
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
return true, nil return true, nil
} }
+42 -1
View File
@@ -2,18 +2,23 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"testing" "testing"
"time" "time"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
) )
type workerRepo struct { type workerRepo struct {
Repository Repository
job model.Notification job model.Notification
saved *model.Notification saved *model.Notification
finishErr error
} }
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil } func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
@@ -21,7 +26,10 @@ func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notificat
n := r.job n := r.job
return &n, nil return &n, nil
} }
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil } func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error {
r.saved = n
return r.finishErr
}
type workerSender struct { type workerSender struct {
calls int calls int
@@ -62,3 +70,36 @@ func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
}) })
} }
} }
func TestWorkerLogsProviderFailureOnce(t *testing.T) {
for _, failPersistence := range []bool{false, true} {
t.Run(fmt.Sprint(failPersistence), func(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
repo := &workerRepo{job: model.Notification{Driver: "resend", ExpiresAt: time.Now().Add(time.Hour), Attempts: 1, Email: model.Email{Address: "private@example.com", Subject: "Private subject", Text: "Private text"}}}
if failPersistence {
repo.finishErr = merrors.ServiceUnavailable("storage offline")
}
sender := &workerSender{outcome: email.Outcome{State: model.Failed, Code: "provider_authentication_failed", HTTPStatus: 403, ProviderError: "validation_error", ProviderMessage: "The sxchnge.com domain is not verified"}}
svc := New(repo, nil, sender, "resend", zap.New(core))
if _, err := svc.Dispatch(context.Background(), "id"); !errors.Is(err, repo.finishErr) {
t.Fatal("unexpected persistence error", err)
}
entries := logs.All()
if len(entries) != 1 || entries[0].Message != "Email delivery attempt finished" {
t.Fatal("expected one provider outcome log", entries)
}
fields := entries[0].ContextMap()
if fields["provider_status"] != int64(403) || fields["provider_error"] != "validation_error" || fields["provider_message"] != "The sxchnge.com domain is not verified" {
t.Fatal("provider diagnostic missing", fields)
}
if repo.saved.FailureCode != "provider_authentication_failed" {
t.Fatal("public failure code changed", repo.saved.FailureCode)
}
for _, field := range []string{"address", "email", "subject", "text", "html", "parameters"} {
if _, ok := fields[field]; ok {
t.Fatalf("private notification field %s logged", field)
}
}
})
}
}
+2 -2
View File
@@ -9,11 +9,11 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template" "git.sendico.io/tech/sxchnge/shared/database/template"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging" mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,7 +21,7 @@ type Store struct {
template.DBImp[*model.Notification] template.DBImp[*model.Notification]
} }
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) { func New(logger mlogger.Logger, db source.Source) (*Store, error) {
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)} s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
ttl := int32(0) ttl := int32(0)
for _, index := range []*ri.Definition{ for _, index := range []*ri.Definition{
+65 -1
View File
@@ -3,7 +3,11 @@
"id": "exchange.completed", "id": "exchange.completed",
"version": "1", "version": "1",
"default_locale": "en", "default_locale": "en",
"parameters": ["first_name", "amount", "currency"], "parameters": [
"first_name",
"amount",
"currency"
],
"locales": { "locales": {
"en": { "en": {
"subject": "Your exchange is complete", "subject": "Your exchange is complete",
@@ -11,5 +15,65 @@
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete." "text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
} }
} }
},
{
"id": "auth.activation",
"version": "1",
"default_locale": "en",
"parameters": [
"url"
],
"locales": {
"en": {
"subject": "Verify your SXCHNGE email",
"text": "Confirm your email: {{.url}}\nThis link expires in 24 hours. If you did not sign up, ignore this email.",
"html": "<p>Confirm your email to finish signing up.</p><p><a href=\"{{.url}}\">Verify email</a></p><p>This link expires in 24 hours. If you did not sign up, ignore this email.</p>"
},
"ru": {
"subject": "Подтвердите почту SXCHNGE",
"text": "Подтвердите почту: {{.url}}\nСсылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.",
"html": "<p><a href=\"{{.url}}\">Подтвердить почту</a></p><p>Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.</p>"
}
}
},
{
"id": "auth.login",
"version": "1",
"default_locale": "en",
"parameters": [
"code"
],
"locales": {
"en": {
"subject": "Your SXCHNGE login code",
"text": "Your login code is {{.code}}. It expires in 10 minutes. Never share this code.",
"html": "<p>Your login code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Never share this code.</p>"
},
"ru": {
"subject": "Код входа SXCHNGE",
"text": "Ваш код входа: {{.code}}. Он действует 10 минут. Никому не сообщайте код.",
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
}
}
},
{
"id": "twofactor.code",
"version": "1",
"default_locale": "en",
"parameters": [
"code"
],
"locales": {
"en": {
"subject": "Your SXCHNGE verification code",
"text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.",
"html": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
},
"ru": {
"subject": "Код подтверждения SXCHNGE",
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
}
}
} }
] ]
+3 -35
View File
@@ -2,21 +2,15 @@ package transport
import ( import (
"context" "context"
"crypto/sha256"
"crypto/subtle"
"errors" "errors"
"strings"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
type callerKey struct{}
type API interface { type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error) Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error) Get(context.Context, string, string) (*model.Notification, error)
@@ -26,14 +20,14 @@ type Server struct {
service API service API
} }
// The private endpoint uses the existing product storage namespace without caller authentication.
func New(service API) *Server { return &Server{service: service} } func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) { func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r) input, err := request(r)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Send(ctx, "product", input)
n, err := s.service.Send(ctx, caller, input)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
if r == nil { if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request") return nil, status.Error(codes.InvalidArgument, "missing request")
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Get(ctx, "product", r.Id)
n, err := s.service.Get(ctx, caller, r.Id)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
return &pb.GetResponse{Notification: response(n)}, nil return &pb.GetResponse{Notification: response(n)}, nil
} }
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
hashes := map[string][32]byte{}
for caller, token := range tokens {
hashes[caller] = sha256.Sum256([]byte(token))
}
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
values := md.Get("authorization")
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
identity := ""
for caller, expected := range hashes {
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
identity = caller
}
}
if identity == "" {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
return handler(context.WithValue(ctx, callerKey{}, identity), req)
}
}
func rpcError(err error) error { func rpcError(err error) error {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return status.Error(codes.Canceled, "notification operation canceled") return status.Error(codes.Canceled, "notification operation canceled")
@@ -15,7 +15,6 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
) )
@@ -31,10 +30,9 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) { func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details") return nil, merrors.NoData("private details")
} }
func TestGRPCAuthenticationAndMapping(t *testing.T) { func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20) listener := bufconn.Listen(1 << 20)
token := strings.Repeat("a", 32) server := grpc.NewServer()
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{})) pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
defer server.Stop() defer server.Stop()
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
defer conn.Close() defer conn.Close()
client := pb.NewNotificationsServiceClient(conn) client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated { ctx := context.Background()
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
result, err := client.Send(ctx, req) result, err := client.Send(ctx, req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+19 -5
View File
@@ -24,15 +24,29 @@ organization, role, policy or seed; the runtime creates its indexes.
The runtime mounts health, login, API login, refresh, rotation and logout routes. The runtime mounts health, login, API login, refresh, rotation and logout routes.
Customer/application-client records must exist for login. The customer service Customer/application-client records must exist for login. The customer service
supports atomic account/activation creation and deletion with session revocation. supports atomic account/activation creation and deletion with session revocation.
Signup/profile HTTP wiring and verification delivery/completion remain follow-up Signup and email verification delivery/completion are available when
work. The frontend still uses dummy services. `api.notifications` is configured. Flutter selects the backend using
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
[auth notification setup and contracts](../../docs/auth-notifications.md).
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 Optional `api.sendico` connects sign-up to the public individual registry and
future public HTTP adapter. There is no unused public API snapshot in this module; immutable Facts intake. The adapter owns organization context and consumed wire
copy only the wire definitions consumed by an implemented adapter. contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate ## Validate
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker `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 for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md). 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).
+32 -14
View File
@@ -1,23 +1,41 @@
package api package api
import ( import (
"context"
"git.sendico.io/tech/sxchnge/product/app/urls" "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/logging"
"git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5"
) )
type API interface { // Dependencies are initialized once and shared by installed transports.
LifecycleContext() context.Context type Dependencies struct {
Logger() mlogger.Logger Stores *storage.Stores
DomainProvider() domainprovider.DomainProvider Customers accountservice.AccountService
Config() *Config Auth *flow.Service
ProfileConfig() ConfigProvider Factors *twofactor.Service
DBFactory() db.Factory Identity *identity.Service
RuntimeComponent(name string) any Payments *payments.Service
Register() Register 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"`
}
+6
View File
@@ -2,10 +2,16 @@ package api
import ( import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config" mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config" verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
) )
type Config struct { 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"` Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"` AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
Verification *verificationconfig.Config `yaml:"verification"` Verification *verificationconfig.Config `yaml:"verification"`
-13
View File
@@ -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)
}
+13 -17
View File
@@ -1,36 +1,32 @@
package api package api
import "git.sendico.io/tech/sxchnge/shared/service" import (
"context"
type ConfigProvider interface { "git.sendico.io/tech/sxchnge/shared/database"
SharedConfig() *Config "git.sendico.io/tech/sxchnge/shared/logging"
} )
type ConfigProvider interface{ SharedConfig() *Config }
type ConfigFactory func() ConfigProvider 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 { type RuntimeConfig struct {
Name string Name string
Services []ServiceFactory
ConfigFactory ConfigFactory ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc Bootstrap Bootstrap
Finish RuntimeFinishFunc Versions map[string]Installer
}
type ServiceFactory struct {
Name mservice.Type
Factory MicroServiceFactoryT
} }
func (cfg RuntimeConfig) RuntimeName() string { func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" { if cfg.Name == "" {
return "edge" return "product"
} }
return cfg.Name return cfg.Name
} }
func (cfg RuntimeConfig) NewConfig() ConfigProvider { func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil { if cfg.ConfigFactory != nil {
return cfg.ConfigFactory() return cfg.ConfigFactory()
+30
View File
@@ -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
}
+6 -56
View File
@@ -2,18 +2,12 @@ package httptransport
import ( import (
"net/http" "net/http"
"os"
"path" "path"
mr "git.sendico.io/tech/sxchnge/product/app/metrics" mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
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"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "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/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
@@ -30,25 +24,13 @@ type Middleware struct {
router *chi.Mux router *chi.Mux
apiEndpoint string apiEndpoint string
metrics mr.Metrics 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)
} }
func (mw *Middleware) Finish() { func (mw *Middleware) Finish() {
if mw.metrics != nil {
mw.metrics.Finish() mw.metrics.Finish()
}
} }
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) { func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...") mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are // Collect metrics for application traffic only. Scrapes and health probes are
@@ -100,14 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics" return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
} }
func CreateMiddleware( func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
logger mlogger.Logger,
db db.Factory,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *middleware.AuthStorageConfig,
debug bool,
) (*Middleware, error) {
if config == nil { if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware") return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
} }
@@ -117,37 +92,12 @@ func CreateMiddleware(
if err := config.CORS.Validate(); err != nil { if err := config.CORS.Validate(); err != nil {
return nil, err return nil, err
} }
loginRateLimits, err := config.ResolveLoginRateLimits() p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
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.installMiddleware(config, debug) p.installMiddleware(config, debug)
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil { var err error
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint)) p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
return nil, err
}
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
p.epdispatcher = httprouter.NewDispatcher(
p.logger,
p.router,
stores.Accounts,
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
config,
loginRateLimits,
)
return p, nil return p, nil
} }
+4 -13
View File
@@ -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 package profile
import ( import (
"git.sendico.io/tech/sxchnge/product/app/api" "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 { func Runtime() api.RuntimeConfig {
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap} return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
}
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
} }
+6 -54
View File
@@ -2,68 +2,20 @@ package runtime
import ( import (
"context" "context"
"sync"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health" amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http" 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/logging"
"git.sendico.io/tech/sxchnge/shared/service"
) )
type Microservices = []mservice.MicroService type instance struct {
// APIImp represents the structure of the APIImp
type APIImp struct {
lifecycleCtx context.Context
logger mlogger.Logger 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 health amr.Health
mw *httptransport.Middleware mw *httptransport.Middleware
cleanup func(context.Context) error
once sync.Once
finishErr error
} }
func (a *APIImp) LifecycleContext() context.Context { func (a *instance) Name() string { return "api" }
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"
}
+57 -79
View File
@@ -6,9 +6,12 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health" 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" httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls" "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/database"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -16,92 +19,67 @@ import (
) )
func CreateAPI( func CreateAPI(
lifecycleCtx context.Context, ctx context.Context,
logger mlogger.Logger, logger mlogger.Logger,
config *api.Config, config *api.Config,
profileConfig api.ConfigProvider,
db db.Factory, db db.Factory,
rootRouter *chi.Mux, rootRouter, apiRouter *chi.Mux,
apiRouter *chi.Mux,
debug bool, debug bool,
runtime api.RuntimeConfig, runtime api.RuntimeConfig,
) (mservice.MicroService, error) { ) (_ mservice.MicroService, result error) {
p := &APIImp{ if config == nil || config.Mw == nil {
lifecycleCtx: lifecycleCtx, return nil, merrors.InvalidArgument("API middleware configuration is required")
logger: logger.Named("api"),
config: config,
profile: profileConfig,
db: db,
runtime: runtime,
} }
endpoint := os.Getenv(config.Mw.EndPointEnv)
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil { mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
return nil, err
}
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
return nil, err
}
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,
); 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)
if err != nil { if err != nil {
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName())) return nil, err
return err
} }
a.components = components if runtime.Bootstrap == nil {
a.logger.Info("Runtime profile bootstrap complete", return nil, merrors.InvalidArgument("runtime bootstrap is required")
zap.String("runtime", a.runtime.RuntimeName()),
zap.Int("components", len(components)))
} }
return nil // 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 {
return nil, err
}
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))
}
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
} }
+26 -3
View File
@@ -2,18 +2,22 @@ package runtime_test
import ( import (
"context" "context"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model" "encoding/base64"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile" "git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime" sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
@@ -24,7 +28,7 @@ import (
func TestProfileStartsOnEmptyDatabase(t *testing.T) { func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute) ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -48,8 +52,9 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
t.Setenv("SXCHNGE_API_PROTOCOL", "http") t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
root, routes := chi.NewRouter(), chi.NewRouter() root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) }) t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes) root.Mount("/", routes)
@@ -60,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
result = httptest.NewRecorder() result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil)) root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code) 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)
} }
+10 -18
View File
@@ -4,28 +4,20 @@ import (
"context" "context"
"git.sendico.io/tech/sxchnge/product/app/health/status" "git.sendico.io/tech/sxchnge/product/app/health/status"
"go.uber.org/zap"
) )
func (a *APIImp) Finish(ctx context.Context) error { func (a *instance) Finish(ctx context.Context) error {
a.once.Do(func() {
if a.health != nil {
a.health.SetStatus(health.SSTerminating) a.health.SetStatus(health.SSTerminating)
a.health.Finish() a.health.Finish()
}
if a.cleanup != nil {
a.finishErr = a.cleanup(ctx)
}
if a.mw != nil {
a.mw.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()))
} }
} })
if a.runtime.Finish != nil { return a.finishErr
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))
}
}
return lastError
} }
+58
View File
@@ -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
}
+190
View File
@@ -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)
}
+83 -25
View File
@@ -1,39 +1,97 @@
package runtime package runtime
import ( import (
"context"
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/health/status" "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/shared/service" "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" "go.uber.org/zap"
) )
func (a *APIImp) installMicroservice(srv mservice.MicroService) { func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
a.services = append(a.services, srv) limits, err := config.Mw.ResolveLoginRateLimits()
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil { if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err)) return nil, nil, err
return err
} }
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
a.installMicroservice(srv) if err != nil {
return nil return nil, nil, err
}
func (a *APIImp) installServices() error {
for _, service := range a.runtime.Services {
if service.Factory == nil {
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
} }
if err := a.addMicroservice(service.Factory); err != nil { customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
return err 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()
} }
a.health.SetStatus(health.SSRunning)
return nil 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
}
}
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/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"os" "os"
"strings"
"testing" "testing"
) )
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
t.Fatal(err) 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() rootRouter := chi.NewRouter()
apiRouter := 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() i.db.CloseConnection()
return err return err
} }
@@ -10,6 +10,7 @@ type DomainProvider interface {
GetAPILink(linkElem ...string) (string, error) 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) { func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint) return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
} }
@@ -2,8 +2,8 @@ package domainproviderimp
import ( import (
"net/url" "net/url"
"os"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
return url.JoinPath(dp.apiEndpoint, linkElem...) 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 := new(DomainProviderImp)
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain) p.domain = protocol + "://" + domain
var err error
apiPath := os.Getenv(apiPointEnv)
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil { 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)) logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
return nil, err 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)
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Package flow owns signup and account activation, independently of second-factor checks.
package flow
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/url"
"strings"
"time"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
account "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/notifications"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"go.mongodb.org/mongo-driver/v2/bson"
)
const Cooldown = time.Minute
type 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
Transactions transaction.Factory
Attempts ratelimits.Store
Sender notifications.Sender
Secret []byte
ActivationURL string
}
func (s *Service) Validate() error {
u, err := url.Parse(s.ActivationURL)
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
}
if len(s.Secret) < 32 {
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
}
return nil
}
func (s *Service) key(parts ...string) string {
h := hmac.New(sha256.New, s.Secret)
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func (s *Service) Limit(ctx context.Context, ip, email string) error {
now := time.Now().UTC()
start := now.Truncate(time.Hour)
for _, scope := range []struct {
kind, value string
limit int
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
if err != nil {
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
}
if count > scope.limit {
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
}
}
return nil
}
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
email, err := customer.NormalizeEmail(a.Login)
if err != nil {
return nil, err
}
a.Login = email
a.Name = strings.TrimSpace(a.Name)
if len(a.Name) > 200 || len(a.Password) > 72 {
return nil, merrors.InvalidArgument("name or password too long")
}
if err = s.Limit(ctx, ip, email); err != nil {
return nil, err
}
raw, err := s.Customers.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.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()
q.Set("token", raw)
u.RawQuery = q.Encode()
locale := "en"
if strings.HasPrefix(a.Locale, "ru") {
locale = "ru"
}
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
} else {
_, err = s.sendActivation(ctx, a, key)
}
return a, err
}
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
email, err := customer.NormalizeEmail(email)
if err != nil {
return err
}
if err = s.Limit(ctx, ip, email); err != nil {
return err
}
a, err := s.Accounts.GetByEmail(ctx, email)
if errors.Is(err, merrors.ErrNoData) {
return nil
}
if err != nil {
return err
}
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
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) 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")
}
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(time.Now()) {
return time.Time{}, verification.ErorrTokenExpired()
}
locale := "en"
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
locale = "ru"
}
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
q.Set("token", result.VerificationToken)
u.RawQuery = q.Encode()
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
return result.ExpiresAt, err
}
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
if len(raw) != 43 {
return "", verification.ErorrTokenNotFound()
}
var email string
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil {
return nil, err
}
a := &customer.Account{}
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
return nil, err
}
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated")
}
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
return nil, err
}
email = a.Login
return nil, nil
})
if err != nil {
return "", err
}
return email, nil
}
+73
View File
@@ -0,0 +1,73 @@
package flow
import (
"context"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/notifications"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"strings"
"testing"
"time"
)
type tokenStub struct {
verification.DB
request *verification.Request
result verification.CreateResult
err error
}
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
s.request = r
return s.result, s.err
}
type senderStub struct {
messages []notifications.Message
err error
}
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
s.messages = append(s.messages, m)
return s.err
}
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
expiry := time.Now().UTC().Add(time.Minute)
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Login = "user@example.com"
a.Locale = "ru-RU"
_, err := service.sendActivation(context.Background(), a, "client-key")
require.Error(t, err)
key := *tokens.request.IdempotencyKey
require.NotEqual(t, "client-key", key)
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
sender.err = nil
_, err = service.sendActivation(context.Background(), a, "client-key")
require.NoError(t, err)
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
require.Len(t, sender.messages, 2)
}
func TestValidateActivationDestination(t *testing.T) {
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
require.NoError(t, s.Validate())
}
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
require.Error(t, s.Validate())
}
}
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" 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/product/customers/store"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
accounts.customer.Status = status accounts.customer.Status = status
routes := chi.NewRouter() routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")} 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 called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc { router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true called = true
@@ -6,9 +6,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"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/ipguard"
emodel "git.sendico.io/tech/sxchnge/product/auth/model" 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/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/product/sessions/metrics"
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type 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) ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja)) router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja)) router.Use(jwtauth.Authenticator(ja))
@@ -1,21 +1,17 @@
package router package router
import ( import (
"os"
"github.com/go-chi/chi/v5"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/verification/store"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
) )
type Dispatcher struct { type Dispatcher struct {
@@ -36,28 +32,28 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
d.protected.PendingAccountHandler(service, endpoint, method, handler) d.protected.PendingAccountHandler(service, endpoint, method, handler)
} }
func NewDispatcher( func Install(
logger mlogger.Logger, logger mlogger.Logger,
router chi.Router, router chi.Router,
endpoint string,
db account.DB, db account.DB,
vdb verification.DB,
rtdb refreshtokens.DB, rtdb refreshtokens.DB,
attempts ratelimits.Store, attempts ratelimits.Store,
config *middleware.Config, token *middleware.TokenConfig,
signature middleware.Signature,
loginRateLimits middleware.LoginRateLimits, loginRateLimits middleware.LoginRateLimits,
flows ...rpublic.Flows,
) *Dispatcher { ) *Dispatcher {
d := &Dispatcher{ d := &Dispatcher{
logger: logger.Named("api_dispatcher"), logger: logger.Named("api_dispatcher"),
} }
d.logger.Debug("Installing endpoints middleware...") d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) { router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits) d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
}) })
router.Group(func(r chi.Router) { router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature) d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
}) })
return d return d
@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" "git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"path" "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" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "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 { 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) { func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
@@ -5,8 +5,8 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
emodel "git.sendico.io/tech/sxchnge/product/auth/model" emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"github.com/go-chi/jwtauth/v5" "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")
}
@@ -5,15 +5,15 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http" "net/http"
"strings" "strings"
clientauth "git.sendico.io/tech/sxchnge/product/auth/client" 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/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request" customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/auth/response" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
@@ -195,7 +195,7 @@ func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.
return nil, response.BadPayload(logger, mservice.Accounts, err) return nil, response.BadPayload(logger, mservice.Accounts, err)
} }
req.Login = strings.TrimSpace(req.Login) req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password) // Preserve passwords exactly as entered.
req.ClientID = strings.TrimSpace(req.ClientID) req.ClientID = strings.TrimSpace(req.ClientID)
req.DeviceID = strings.TrimSpace(req.DeviceID) req.DeviceID = strings.TrimSpace(req.DeviceID)
req.ClientSecret = strings.TrimSpace(req.ClientSecret) req.ClientSecret = strings.TrimSpace(req.ClientSecret)
@@ -225,6 +225,9 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
if h = a.validateClientAccountBinding(client, account); h != nil { if h = a.validateClientAccountBinding(client, account); h != nil {
return h return h
} }
if a.factors != nil {
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
}
return a.respondPendingLogin(account) return a.respondPendingLogin(account)
} }
@@ -9,8 +9,8 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/ipguard" "git.sendico.io/tech/sxchnge/product/auth/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -13,7 +13,7 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap" "go.uber.org/zap"
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"git.sendico.io/tech/sxchnge/product/auth/config" "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/errors"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -183,11 +183,10 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
PrivateKey: []byte("test-secret"), PrivateKey: []byte("test-secret"),
} }
router := NewRouter( router := Install(
zap.NewNop(), zap.NewNop(),
"/", "/",
&testAccountDB{result: account}, &testAccountDB{result: account},
nil,
rtdb, rtdb,
nil, nil,
chi.NewRouter(), chi.NewRouter(),
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "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/browser"
"git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
@@ -0,0 +1,35 @@
package public
import (
"net/http"
"time"
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"
)
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
return func(r *http.Request) http.HandlerFunc {
start := time.Now()
response := handler(r)
return func(w http.ResponseWriter, r *http.Request) {
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
response(wrapped, r)
outcome := "accepted"
if wrapped.Status() >= 500 {
outcome = "unavailable"
} else if wrapped.Status() == 429 {
outcome = "limited"
} else if wrapped.Status() >= 400 {
outcome = "rejected"
}
flowRequests.WithLabelValues(operation, outcome).Inc()
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
}
}
}
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
@@ -47,11 +47,10 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
Algorithm: "HS256", Algorithm: "HS256",
PrivateKey: []byte("browser-refresh-test-secret"), PrivateKey: []byte("browser-refresh-test-secret"),
} }
return NewRouter( return Install(
zap.NewNop(), zap.NewNop(),
"/api/v1", "/api/v1",
&testAccountDB{result: account}, &testAccountDB{result: account},
nil,
rtdb, rtdb,
nil, nil,
chi.NewRouter(), chi.NewRouter(),
@@ -6,7 +6,7 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http" "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" rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
session *sessionmodel.SessionIdentifier, session *sessionmodel.SessionIdentifier,
account *customermodel.Account, account *customermodel.Account,
) http.HandlerFunc { ) http.HandlerFunc {
result, err := pr.prepareLogin(ctx, r, session, account)
if err != nil {
return response.Internal(pr.logger, pr.service, err)
}
return result
}
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
refreshToken, err := rtokens.PrepareRefreshToken( refreshToken, err := rtokens.PrepareRefreshToken(
ctx, ctx,
r, r,
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
if err != nil { if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account), pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err) return nil, err
} }
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier) accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
if err != nil { if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account), pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID)) zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err) return nil, err
} }
token := sresponse.TokenData{ token := sresponse.TokenData{
Token: refreshToken.RefreshToken, Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt, Expiration: refreshToken.ExpiresAt,
} }
return sresponse.Login(pr.logger, account, &accessToken, &token) return sresponse.Login(pr.logger, account, &accessToken, &token), nil
} }
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "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/http/response"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap" "go.uber.org/zap"
@@ -4,11 +4,12 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" "git.sendico.io/tech/sxchnge/product/auth/flow"
"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/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/verification/store" "git.sendico.io/tech/sxchnge/product/twofactor"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -16,7 +17,14 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Flows struct {
Onboarding *flow.Service
Factors twofactor.API
}
type PublicRouter struct { type PublicRouter struct {
factors twofactor.API
flow *flow.Service
logger mlogger.Logger logger mlogger.Logger
db account.DB db account.DB
imp *re.HttpEndpointRouter imp *re.HttpEndpointRouter
@@ -33,17 +41,17 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
pr.imp.InstallHandler(service, endpoint, method, handler) pr.imp.InstallHandler(service, endpoint, method, handler)
} }
func NewRouter( func Install(
logger mlogger.Logger, logger mlogger.Logger,
apiEndpoint string, apiEndpoint string,
db account.DB, db account.DB,
vdb verification.DB,
rtdb refreshtokens.DB, rtdb refreshtokens.DB,
attempts ratelimits.Store, attempts ratelimits.Store,
router chi.Router, router chi.Router,
config *middleware.TokenConfig, config *middleware.TokenConfig,
signature *middleware.Signature, signature *middleware.Signature,
limits middleware.LoginRateLimits, limits middleware.LoginRateLimits,
flows ...Flows,
) *PublicRouter { ) *PublicRouter {
l := logger.Named("public") l := logger.Named("public")
hr := PublicRouter{ hr := PublicRouter{
@@ -59,7 +67,20 @@ func NewRouter(
now: time.Now, now: time.Now,
} }
hr.InstallHandler(hr.service, "/login", api.Post, hr.login) if len(flows) > 0 {
hr.flow = flows[0].Onboarding
hr.factors = flows[0].Factors
}
if hr.flow != nil {
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
}
if hr.factors != nil {
hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin))
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
}
hr.InstallHandler(hr.service, "/login", api.Post, measured("login", hr.login))
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin) hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken) hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken) hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
@@ -8,8 +8,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -0,0 +1,265 @@
package public
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"time"
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"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response"
"github.com/go-chi/jwtauth/v5"
"go.uber.org/zap"
)
type signupRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Locale string `json:"locale"`
Key string `json:"idempotencyKey"`
}
type challengeRequest struct {
ChallengeID string `json:"challengeId"`
PendingToken string `json:"pendingToken"`
Code string `json:"code"`
Key string `json:"idempotencyKey"`
Session session.SessionIdentifier `json:"sessionIdentifier"`
}
type challengeResponse struct {
ChallengeID string `json:"challengeId"`
Channel factor.Channel `json:"channel"`
UserID string `json:"userId"`
PendingToken sresponse.TokenData `json:"pendingToken"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func decodeFlow(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if err := d.Decode(dst); err != nil {
return merrors.InvalidArgument("invalid request")
}
var extra any
if err := d.Decode(&extra); err != io.EOF {
return merrors.InvalidArgument("invalid request")
}
return nil
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
if details, ok := verification.Throttle(err); ok {
return pr.loginRateLimited(details.RetryAfter)
}
switch {
case errors.Is(err, 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):
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
case errors.Is(err, merrors.ErrDataConflict):
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
case errors.Is(err, merrors.ErrUnauthorized):
return response.Unauthorized(pr.logger, pr.service, "credentials required")
default:
// Do not include downstream errors: they may contain transport/request details.
pr.logger.Warn("Authentication flow dependency failed")
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
}
}
}
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
var req signupRequest
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
if req.Key == "" || len(req.Key) > 128 {
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
}
a := &customer.Account{Password: req.Password}
a.Name = req.Name
a.Login = req.Email
a.Locale = req.Locale
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
if err != nil {
return pr.flowError(err)
}
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
}
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
var req struct {
Email string `json:"email"`
Key string `json:"idempotencyKey"`
}
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
if req.Key == "" || len(req.Key) > 128 {
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
}
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
return pr.flowError(err)
}
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
}
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
var req struct {
Token string `json:"token"`
}
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
email, err := pr.flow.Activate(r.Context(), req.Token)
if err != nil {
return pr.flowError(err)
}
pr.logger.Info("Account activation completed")
return response.Accepted(pr.logger, struct {
Verified bool `json:"verified"`
Email string `json:"email"`
}{Verified: true, Email: email})
}
func challengeScope(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
return pr.flowError(merrors.InvalidArgument("session identifier required"))
}
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
claims["clientId"] = sessionID.ClientID
claims["deviceId"] = sessionID.DeviceID
// Independent challenges, even when password requests arrive within one second.
claims["nonce"] = randomKey()
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
_, raw, err := ja.Encode(claims)
if err != nil {
return pr.flowError(err)
}
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
return pr.queueLogin(r, a, token, key, "")
}
func randomKey() string { return rand.Text() }
func loginScope(raw string) factor.Scope {
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
}
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
if pr.factors == nil {
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
}
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
var challenge factor.Challenge
var err error
if id == "" {
challenge, err = pr.factors.Begin(r.Context(), req)
} else {
challenge, err = pr.factors.Resend(r.Context(), req, id)
}
if err != nil {
return pr.flowError(err)
}
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
}
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
if len(raw) > 8192 {
return nil, nil, merrors.Unauthorized("invalid pending token")
}
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
t, err := jwtauth.VerifyToken(ja, raw)
if err != nil {
return nil, nil, merrors.Unauthorized("invalid pending token")
}
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
if err != nil {
return nil, nil, err
}
token, err := authmodel.Claims2Token(claims)
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
return nil, nil, merrors.Unauthorized("pending login required")
}
a := &customer.Account{}
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
return nil, nil, err
}
if !a.IsActive() || a.IsArchived() {
return nil, nil, merrors.Unauthorized("inactive account")
}
return a, token, nil
}
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
var req challengeRequest
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
if req.ChallengeID == "" {
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
}
a, t, err := pr.pending(r.Context(), req.PendingToken)
if err != nil {
return pr.flowError(err)
}
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
}
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
var req challengeRequest
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
a, t, err := pr.pending(r.Context(), req.PendingToken)
if err != nil {
return pr.flowError(err)
}
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
if h != nil {
return h
}
if h = pr.validateClientAccountBinding(client, a); h != nil {
return h
}
if pr.factors == nil {
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
}
scope := loginScope(req.PendingToken)
var result http.HandlerFunc
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
var err error
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
return err
})
if err != nil {
return pr.flowError(err)
}
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
return result
}
@@ -0,0 +1,214 @@
package public
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/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"
)
type capturedMail struct {
messages []notifications.Message
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 {
return merrors.ServiceUnavailable("test queue unavailable")
}
return nil
}
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "auth_flow"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
require.NoError(t, err)
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
require.NoError(t, err)
mail := &capturedMail{}
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
require.NoError(t, service.Validate())
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()
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)
r := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+path, strings.NewReader(string(raw)))
r.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
routes.ServeHTTP(w, r)
return w
}
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
w := post("signup", signup)
require.Equal(t, 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)
require.Equal(t, "ru", mail.messages[0].Locale)
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
require.NoError(t, err)
activation := activationURL.Query().Get("token")
require.NotEmpty(t, activation)
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
require.NoError(t, err)
require.False(t, a.IsActive())
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)
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
require.Equal(t, 400, invalid.Code)
require.NotContains(t, invalid.Body.String(), a.Login)
guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard
blocked := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 503, blocked.Code)
require.NotContains(t, blocked.Body.String(), a.Login)
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, "no-store", w.Header().Get("Cache-Control"))
var confirmed struct {
Data struct {
Verified bool `json:"verified"`
Email string `json:"email"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
require.True(t, confirmed.Data.Verified)
require.Equal(t, a.Login, confirmed.Data.Email)
used := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 400, used.Code)
require.NotContains(t, used.Body.String(), a.Login)
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status)
w = post("login", login)
require.Equal(t, 202, w.Code, w.Body.String())
require.Len(t, mail.messages, 2)
require.Empty(t, sessions.tokens)
var pending struct {
Data challengeResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
require.NotEmpty(t, pending.Data.PendingToken.Token)
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
code := mail.messages[1].Parameters["code"]
require.Len(t, code, 6)
wrong := "000000"
if code == wrong {
wrong = "111111"
}
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
require.Empty(t, sessions.tokens)
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code})
require.Equal(t, 200, w.Code, w.Body.String())
require.Len(t, sessions.tokens, 1)
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
require.Len(t, sessions.tokens, 1)
// A queue outage cannot report a successful login challenge or create a session.
mail.fail = true
require.Equal(t, 503, post("login", login).Code)
require.Len(t, sessions.tokens, 1)
}
@@ -1,7 +1,7 @@
package router package router
import ( 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" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
) )
+14
View File
@@ -9,6 +9,17 @@ database:
uri_env: SXCHNGE_MONGO_URI uri_env: SXCHNGE_MONGO_URI
database: sxchnge database: sxchnge
api: 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: middleware:
domain_env: SXCHNGE_DOMAIN domain_env: SXCHNGE_DOMAIN
api_endpoint_env: SXCHNGE_API_ENDPOINT api_endpoint_env: SXCHNGE_API_ENDPOINT
@@ -31,3 +42,6 @@ api:
token_length: 32 token_length: 32
check: check:
min_length: 12 min_length: 12
notifications:
address: localhost:8083
activation_url: http://localhost:5126/verify-email
@@ -2,6 +2,10 @@ package accountserviceimp
import ( import (
"context" "context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model" customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
@@ -15,9 +19,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap" "go.uber.org/zap"
"net/url"
"testing"
"time"
) )
func customer(login string) *customermodel.Account { func customer(login string) *customermodel.Account {
@@ -29,7 +30,7 @@ func customer(login string) *customermodel.Account {
func TestCustomerLifecycleWithoutTenancy(t *testing.T) { func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -86,7 +87,10 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
deps, err := edgemongo.ResolveDependencies(factory) deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err) require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{}) raw, rawContext, release, err := deps.Database.Acquire(ctx)
require.NoError(t, err)
defer release()
collections, err := raw.ListCollectionNames(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} { for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden) require.NotContains(t, collections, forbidden)
@@ -116,7 +120,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
require.ErrorIs(t, err, outerFailure) require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test") _, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData) require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{}) count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 2, count) require.EqualValues(t, 2, count)
} }
+21 -3
View File
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
ctx context.Context, ctx context.Context,
acct *customermodel.Account, acct *customermodel.Account,
) (string, error) { ) (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 { if acct == nil {
return "", merrors.InvalidArgument("account is required") return "", merrors.InvalidArgument("account is required")
} }
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
if err := s.accountDB.Create(tx, saved); err != nil { if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err 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 err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
@@ -243,6 +254,13 @@ func NewAccountService(
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &service{logger: logger, config: config, accountDB: stores.Accounts, return FromStores(l, config, stores, dbf.TransactionFactory())
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil }
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
} }
+16
View File
@@ -0,0 +1,16 @@
package model
import (
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"net/mail"
"strings"
)
func NormalizeEmail(email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
a, err := mail.ParseAddress(email)
if err != nil || a.Address != email || len(email) > 254 {
return "", merrors.InvalidArgument("valid email required")
}
return email, nil
}
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more