Compare commits
1
Commits
30f0455122
..
bff-34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c822e2ed7b |
No files matched your search
@@ -1,16 +0,0 @@
|
|||||||
.git
|
|
||||||
.local
|
|
||||||
**/.dart_tool
|
|
||||||
**/build
|
|
||||||
**/.env*
|
|
||||||
**/node_modules
|
|
||||||
**/*.pb.go
|
|
||||||
frontend/app/lib/generated
|
|
||||||
**/.DS_Store
|
|
||||||
dist
|
|
||||||
website
|
|
||||||
**/config.yml
|
|
||||||
**/tls
|
|
||||||
**/*.pem
|
|
||||||
**/*.key
|
|
||||||
**/__pycache__
|
|
||||||
@@ -34,9 +34,3 @@ pnpm-debug.log*
|
|||||||
/backend/notifications/interface/notifications/**/*.pb.go
|
/backend/notifications/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__/
|
|
||||||
@@ -8,36 +8,6 @@
|
|||||||
- 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**
|
||||||
@@ -169,18 +139,3 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
|
|||||||
into task commits. Record scope, validation, limitations, and PR links in the
|
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.
|
|
||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -17,12 +16,10 @@ test-frontend:
|
|||||||
.PHONY: test-backend lint-backend run-backend
|
.PHONY: test-backend lint-backend run-backend
|
||||||
|
|
||||||
test-backend: generate-notifications
|
test-backend: generate-notifications
|
||||||
docker pull mongo:latest
|
cd backend/shared && go test -race ./...
|
||||||
docker pull nats:latest
|
cd backend/notifications/interface && go test -race ./...
|
||||||
cd backend/shared && go test -race -count=1 ./...
|
cd backend/notifications && go test -race ./...
|
||||||
cd backend/notifications/interface && go test -race -count=1 ./...
|
cd backend/product && go test -race ./...
|
||||||
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
|
||||||
@@ -51,44 +48,3 @@ package-notifications-interface: generate-notifications
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
||||||
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
|
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
|
||||||
|
|
||||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
|
||||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
|
||||||
.PHONY: build up down logs ps test-dev
|
|
||||||
build:
|
|
||||||
sh infra/local/tools.sh build
|
|
||||||
sh infra/local/tools.sh images
|
|
||||||
$(COMPOSE) pull vault mongo
|
|
||||||
$(COMPOSE) build --pull
|
|
||||||
|
|
||||||
up:
|
|
||||||
sh infra/local/tools.sh up
|
|
||||||
|
|
||||||
down:
|
|
||||||
$(COMPOSE) down
|
|
||||||
|
|
||||||
logs:
|
|
||||||
$(COMPOSE) logs --tail=100 -f
|
|
||||||
|
|
||||||
ps:
|
|
||||||
$(COMPOSE) ps
|
|
||||||
|
|
||||||
test-dev:
|
|
||||||
sh infra/local/tools.sh test
|
|
||||||
sh infra/local/tools.sh verify
|
|
||||||
|
|
||||||
.PHONY: test-dev-infra
|
|
||||||
test-dev-infra:
|
|
||||||
sh infra/local/tools.sh test
|
|
||||||
sh infra/local/tools.sh verify-infra
|
|
||||||
|
|
||||||
.PHONY: test-dev-rotation
|
|
||||||
test-dev-rotation:
|
|
||||||
sh infra/local/tools.sh rotation
|
|
||||||
|
|
||||||
.PHONY: email test-dev-tooling
|
|
||||||
email:
|
|
||||||
sh infra/local/tools.sh email
|
|
||||||
|
|
||||||
test-dev-tooling:
|
|
||||||
sh infra/local/tools.sh test
|
|
||||||
@@ -32,24 +32,6 @@ Each application owns its dependencies and build configuration. Shared Dart and
|
|||||||
Flutter packages can be reused by a future mobile app; mobile runners are not
|
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
|
||||||
@@ -72,8 +54,7 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
|
|||||||
repository root:
|
repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm --pull always -p 127.0.0.1:5123:80 \
|
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web
|
||||||
-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
|
||||||
|
|||||||
@@ -71,11 +71,3 @@ code. Auth and verification configurations moved out of application interfaces.
|
|||||||
All 112 unconsumed public API snapshot files and their snapshot-only check were
|
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).
|
|
||||||
@@ -22,12 +22,12 @@ subject, text and HTML, bounded timeouts and safe error classification.
|
|||||||
|
|
||||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||||
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
||||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
|
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for
|
||||||
variable. Configure a verified sender address and template
|
each configured caller, and the selected email provider's API key environment
|
||||||
|
variable. Configure a verified sender address, TLS certificate/key and template
|
||||||
catalog. Paths are relative to the process working directory.
|
catalog. Paths are relative to the process working directory.
|
||||||
|
|
||||||
From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
|
From the root, run `make run-notifications`. The gRPC listener always uses TLS.
|
||||||
`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,8 +46,7 @@ Multiple service workers can safely compete for jobs through atomic claims.
|
|||||||
|
|
||||||
## Templates and parameters
|
## Templates and parameters
|
||||||
|
|
||||||
See `templates.example.json`, including the `auth.activation` and `auth.login`
|
See `templates.example.json`. Parameters use Go template map syntax such as
|
||||||
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
|
|
||||||
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
|
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
|
||||||
locale-specific content. Missing/extra parameters, unknown revisions/locales and
|
locale-specific content. Missing/extra parameters, unknown revisions/locales and
|
||||||
invalid content are rejected before acceptance. Empty locale uses the default.
|
invalid content are rejected before acceptance. Empty locale uses the default.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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"
|
||||||
@@ -21,6 +22,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,14 +40,19 @@ 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.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
|
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 {
|
||||||
return c, merrors.InvalidArgument("missing notification configuration")
|
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
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,12 @@ 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)
|
||||||
@@ -14,6 +16,11 @@ 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)
|
||||||
@@ -26,30 +33,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,9 +33,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
options, err := serverOptions(c)
|
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||||
}
|
}
|
||||||
client, err := db.ConnectMongo(logger, &c.Database)
|
client, err := db.ConnectMongo(logger, &c.Database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +63,11 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer broker.Close()
|
defer broker.Close()
|
||||||
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
|
tokens := map[string]string{}
|
||||||
|
for caller, env := range c.Callers {
|
||||||
|
tokens[caller] = os.Getenv(env)
|
||||||
|
}
|
||||||
|
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
|
||||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
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 {
|
||||||
@@ -98,15 +103,3 @@ 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
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
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:
|
||||||
@@ -12,6 +14,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
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -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 use the trusted private network without caller authentication. The service
|
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service
|
||||||
uses the existing product storage namespace. Callers authorize user/destination
|
maps tokens to configured caller identities. 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 product namespace and retained at least seven days;
|
Keys are scoped to the authenticated caller and retained at least seven days;
|
||||||
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
|
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 on the trusted private network; no caller authentication. Send durably queues work.
|
// Internal service: authenticate callers over TLS. 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 the product namespace, retained seven days.
|
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days.
|
||||||
string user_id = 2; // Caller resolves and authorizes recipient ownership.
|
string 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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service_test
|
package service_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -16,9 +17,10 @@ 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 optional TLS transport against the real service and storage.
|
// Exercise authenticated TLS transport against the real service and storage.
|
||||||
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
|
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)
|
||||||
@@ -40,7 +42,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}}})))
|
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"})))
|
||||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||||
go func() { _ = server.Serve(listener) }()
|
go func() { _ = server.Serve(listener) }()
|
||||||
t.Cleanup(server.Stop)
|
t.Cleanup(server.Stop)
|
||||||
@@ -51,3 +53,6 @@ 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,6 +26,8 @@ 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 {
|
||||||
@@ -48,7 +50,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:latest")
|
container, err := mongodb.Run(ctx, "mongo:8.0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -190,7 +192,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:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -231,8 +233,11 @@ func TestDurableDelivery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := grpcClient(t, svc)
|
client := grpcClient(t, svc)
|
||||||
rpcCtx := ctx
|
rpcCtx := callerContext(ctx, "product")
|
||||||
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
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)
|
||||||
@@ -241,8 +246,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(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
|
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound {
|
||||||
t.Fatal("RPC retrieval without credentials failed", e)
|
t.Fatal("RPC caller isolation failed", e)
|
||||||
}
|
}
|
||||||
if err = broker.PublishDue(ctx); err != nil {
|
if err = broker.PublishDue(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -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 source.Source) (*Store, error) {
|
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) {
|
||||||
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
|
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{
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
"id": "exchange.completed",
|
"id": "exchange.completed",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"parameters": [
|
"parameters": ["first_name", "amount", "currency"],
|
||||||
"first_name",
|
|
||||||
"amount",
|
|
||||||
"currency"
|
|
||||||
],
|
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {
|
||||||
"subject": "Your exchange is complete",
|
"subject": "Your exchange is complete",
|
||||||
@@ -15,65 +11,5 @@
|
|||||||
"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>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2,15 +2,21 @@ 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)
|
||||||
@@ -20,14 +26,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)
|
||||||
}
|
}
|
||||||
n, err := s.service.Send(ctx, "product", input)
|
caller, _ := ctx.Value(callerKey{}).(string)
|
||||||
|
n, err := s.service.Send(ctx, caller, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, rpcError(err)
|
return nil, rpcError(err)
|
||||||
}
|
}
|
||||||
@@ -37,13 +43,39 @@ 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")
|
||||||
}
|
}
|
||||||
n, err := s.service.Get(ctx, "product", r.Id)
|
caller, _ := ctx.Value(callerKey{}).(string)
|
||||||
|
n, err := s.service.Get(ctx, caller, r.Id)
|
||||||
if err != nil {
|
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,6 +15,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -30,9 +31,10 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
|
|||||||
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
||||||
return nil, merrors.NoData("private details")
|
return nil, merrors.NoData("private details")
|
||||||
}
|
}
|
||||||
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
||||||
listener := bufconn.Listen(1 << 20)
|
listener := bufconn.Listen(1 << 20)
|
||||||
server := grpc.NewServer()
|
token := strings.Repeat("a", 32)
|
||||||
|
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
|
||||||
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
||||||
go func() { _ = server.Serve(listener) }()
|
go func() { _ = server.Serve(listener) }()
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
@@ -43,7 +45,10 @@ func TestGRPCWithoutAuthenticationAndMapping(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"}}}}}
|
||||||
ctx := context.Background()
|
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
|
||||||
result, err := client.Send(ctx, req)
|
result, err := client.Send(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ 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 and email verification delivery/completion are available when
|
Signup/profile HTTP wiring and verification delivery/completion remain follow-up
|
||||||
`api.notifications` is configured. Flutter selects the backend using
|
work. The frontend still uses dummy services.
|
||||||
`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
|
Sendico integration is not wired. Its organization context belongs only to the
|
||||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
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/product/auth/config"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
"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/twofactor"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"github.com/go-chi/chi/v5"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dependencies are initialized once and shared by installed transports.
|
type API interface {
|
||||||
type Dependencies struct {
|
LifecycleContext() context.Context
|
||||||
Stores *storage.Stores
|
Logger() mlogger.Logger
|
||||||
Customers accountservice.AccountService
|
DomainProvider() domainprovider.DomainProvider
|
||||||
Auth *flow.Service
|
Config() *Config
|
||||||
Factors *twofactor.Service
|
ProfileConfig() ConfigProvider
|
||||||
Token middleware.TokenConfig
|
DBFactory() db.Factory
|
||||||
Signature middleware.Signature
|
RuntimeComponent(name string) any
|
||||||
LoginLimits middleware.LoginRateLimits
|
Register() Register
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope belongs to one installation. Router paths are relative to Mount.
|
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// 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"`
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Register interface {
|
||||||
|
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||||
|
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||||
|
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||||
|
}
|
||||||
@@ -1,32 +1,36 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import "git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
type ConfigProvider interface {
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
SharedConfig() *Config
|
||||||
)
|
}
|
||||||
|
|
||||||
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 Bootstrap
|
Bootstrap RuntimeBootstrapFunc
|
||||||
Versions map[string]Installer
|
Finish RuntimeFinishFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "product"
|
return "edge"
|
||||||
}
|
}
|
||||||
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()
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
// 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"
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,18 @@ 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"
|
||||||
@@ -24,13 +30,25 @@ 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
|
||||||
@@ -82,7 +100,14 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
|||||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
func CreateMiddleware(
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
@@ -92,12 +117,37 @@ func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middlew
|
|||||||
if err := config.CORS.Validate(); err != nil {
|
if err := config.CORS.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &Middleware{
|
||||||
|
logger: logger.Named("middleware"),
|
||||||
|
router: apiRouter,
|
||||||
|
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||||
|
}
|
||||||
|
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||||
p.installMiddleware(config, debug)
|
p.installMiddleware(config, debug)
|
||||||
var err error
|
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||||
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
// Package profile selects the product's supported API implementations.
|
// Package profile selects the locally owned components of the copied BFF.
|
||||||
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/app/api/v1"
|
"git.sendico.io/tech/sxchnge/product/customers"
|
||||||
"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: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrap(a api.API) (api.RuntimeComponents, error) {
|
||||||
|
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.RuntimeComponents{Accounts: accounts}, nil
|
||||||
}
|
}
|
||||||
@@ -2,20 +2,68 @@ 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 instance struct {
|
type Microservices = []mservice.MicroService
|
||||||
|
|
||||||
|
// 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 *instance) Name() string { return "api" }
|
func (a *APIImp) LifecycleContext() context.Context {
|
||||||
|
return a.lifecycleCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) Logger() mlogger.Logger {
|
||||||
|
return a.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) Config() *api.Config {
|
||||||
|
return a.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||||
|
return a.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) DBFactory() db.Factory {
|
||||||
|
return a.db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||||
|
return a.domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) Register() api.Register {
|
||||||
|
return a.mw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) RuntimeComponent(name string) any {
|
||||||
|
if a == nil || a.components == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.components[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) Name() string {
|
||||||
|
return "api"
|
||||||
|
}
|
||||||
@@ -6,12 +6,9 @@ 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"
|
||||||
@@ -19,67 +16,92 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CreateAPI(
|
func CreateAPI(
|
||||||
ctx context.Context,
|
lifecycleCtx context.Context,
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
config *api.Config,
|
config *api.Config,
|
||||||
|
profileConfig api.ConfigProvider,
|
||||||
db db.Factory,
|
db db.Factory,
|
||||||
rootRouter, apiRouter *chi.Mux,
|
rootRouter *chi.Mux,
|
||||||
|
apiRouter *chi.Mux,
|
||||||
debug bool,
|
debug bool,
|
||||||
runtime api.RuntimeConfig,
|
runtime api.RuntimeConfig,
|
||||||
) (_ mservice.MicroService, result error) {
|
) (mservice.MicroService, error) {
|
||||||
if config == nil || config.Mw == nil {
|
p := &APIImp{
|
||||||
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
lifecycleCtx: lifecycleCtx,
|
||||||
|
logger: logger.Named("api"),
|
||||||
|
config: config,
|
||||||
|
profile: profileConfig,
|
||||||
|
db: db,
|
||||||
|
runtime: runtime,
|
||||||
}
|
}
|
||||||
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
|
||||||
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if runtime.Bootstrap == nil {
|
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||||
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
|
||||||
}
|
|
||||||
// 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
|
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))
|
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||||
}
|
if err := p.installServices(); err != nil {
|
||||||
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
|
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
|
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||||
a.cleanup = cleanup
|
|
||||||
if err != nil {
|
return p, nil
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
for i, mount := range mounts {
|
a.logger.Info("Domain provider installed")
|
||||||
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
|
|
||||||
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
|
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
|
||||||
}
|
}
|
||||||
// Attach only after every installer succeeds.
|
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||||
for i, mount := range mounts {
|
return nil
|
||||||
apiRouter.Mount(mount.Path, scopes[i].Router)
|
}
|
||||||
scopes[i].Logger.Info("API version installed")
|
|
||||||
|
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||||
|
if a.runtime.Bootstrap != nil {
|
||||||
|
components, err := a.runtime.Bootstrap(a)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
|
a.components = components
|
||||||
if err != nil {
|
a.logger.Info("Runtime profile bootstrap complete",
|
||||||
return nil, err
|
zap.String("runtime", a.runtime.RuntimeName()),
|
||||||
|
zap.Int("components", len(components)))
|
||||||
}
|
}
|
||||||
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
|
return nil
|
||||||
a.health.SetStatus(health.SSRunning)
|
|
||||||
return a, nil
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package runtime_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
container, err := mongodb.Run(ctx, "mongo:8", 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,9 +48,8 @@ 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, factory, root, routes, false, profile.Runtime())
|
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
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)
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ 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 *instance) Finish(ctx context.Context) error {
|
func (a *APIImp) 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()))
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
return a.finishErr
|
if a.runtime.Finish != nil {
|
||||||
|
if err := a.runtime.Finish(a); err != nil {
|
||||||
|
lastError = err
|
||||||
|
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastError
|
||||||
}
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package runtime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testConfig(t *testing.T) *api.Config {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile("../../config.example.yml")
|
|
||||||
require.NoError(t, err)
|
|
||||||
var raw struct {
|
|
||||||
API api.Config `yaml:"api"`
|
|
||||||
}
|
|
||||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
|
||||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
|
||||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
|
||||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
|
||||||
return &raw.API
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
|
||||||
config := testConfig(t)
|
|
||||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
|
||||||
initialized, closed := 0, 0
|
|
||||||
deps := &api.Dependencies{}
|
|
||||||
installer := func(version string) api.Installer {
|
|
||||||
return func(scope api.Scope, got *api.Dependencies) error {
|
|
||||||
require.Same(t, deps, got)
|
|
||||||
link, err := scope.URLs.GetAPILink("accounts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
|
||||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
|
||||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
|
||||||
})
|
|
||||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
|
||||||
initialized++
|
|
||||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
|
||||||
}}
|
|
||||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
|
||||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
|
||||||
require.NoError(t, err)
|
|
||||||
root.Mount("/", routes)
|
|
||||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
|
||||||
result := httptest.NewRecorder()
|
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
|
||||||
require.Equal(t, http.StatusOK, result.Code)
|
|
||||||
require.Equal(t, expected, result.Body.String())
|
|
||||||
}
|
|
||||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
|
||||||
for _, mount := range config.Versions {
|
|
||||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
|
||||||
result := httptest.NewRecorder()
|
|
||||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
|
||||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
|
||||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
|
||||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
|
||||||
root.ServeHTTP(result, request)
|
|
||||||
require.Equal(t, http.StatusOK, result.Code)
|
|
||||||
cookies := result.Result().Cookies()
|
|
||||||
require.Len(t, cookies, 1)
|
|
||||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
|
||||||
if method == http.MethodDelete {
|
|
||||||
require.Equal(t, -1, cookies[0].MaxAge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := httptest.NewRecorder()
|
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
|
||||||
require.Equal(t, 404, result.Code)
|
|
||||||
require.Equal(t, 1, initialized)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
results := make(chan error, 3)
|
|
||||||
for range 3 {
|
|
||||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(results)
|
|
||||||
for err := range results {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
require.Equal(t, 1, closed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
|
||||||
for name, mounts := range map[string][]api.Mount{
|
|
||||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
|
||||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
|
||||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
|
||||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
|
||||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
|
||||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
|
||||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
|
||||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
config := testConfig(t)
|
|
||||||
config.Versions = mounts
|
|
||||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
|
||||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
|
||||||
t.Fatal("bootstrap must not run")
|
|
||||||
return nil, nil, nil
|
|
||||||
}})
|
|
||||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
|
||||||
config := testConfig(t)
|
|
||||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
|
||||||
closed := 0
|
|
||||||
cause := merrors.InvalidArgument("test installation failure")
|
|
||||||
routes := chi.NewRouter()
|
|
||||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
|
||||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
|
||||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
|
||||||
},
|
|
||||||
Versions: map[string]api.Installer{
|
|
||||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
|
||||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
|
||||||
}})
|
|
||||||
require.True(t, errors.Is(err, cause))
|
|
||||||
require.Equal(t, 1, closed)
|
|
||||||
result := httptest.NewRecorder()
|
|
||||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
|
||||||
require.Equal(t, 404, result.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
|
||||||
config := testConfig(t)
|
|
||||||
config.Versions = []api.Mount{}
|
|
||||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
|
||||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
|
||||||
return &api.Dependencies{}, nil, nil
|
|
||||||
}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer srv.Finish(context.Background())
|
|
||||||
root.Mount("/", routes)
|
|
||||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
|
||||||
result := httptest.NewRecorder()
|
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
|
||||||
require.Equal(t, status, result.Code, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
|
||||||
config := testConfig(t)
|
|
||||||
config.Versions = []api.Mount{}
|
|
||||||
closed := 0
|
|
||||||
cause := merrors.Internal("bootstrap failure")
|
|
||||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
|
||||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
|
||||||
}})
|
|
||||||
require.ErrorIs(t, err, cause)
|
|
||||||
require.Equal(t, 1, closed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
|
||||||
cause := merrors.Internal("cleanup failure")
|
|
||||||
closed := 0
|
|
||||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
|
||||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
|
||||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
|
||||||
require.Equal(t, 1, closed)
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,39 @@
|
|||||||
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/auth/config"
|
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
"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"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||||
limits, err := config.Mw.ResolveLoginRateLimits()
|
a.services = append(a.services, srv)
|
||||||
|
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||||
|
srv, err := srvf(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
|
||||||
if err != nil {
|
a.installMicroservice(srv)
|
||||||
return nil, nil, err
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) installServices() error {
|
||||||
|
for _, service := range a.runtime.Services {
|
||||||
|
if service.Factory == nil {
|
||||||
|
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||||
}
|
}
|
||||||
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
if err := a.addMicroservice(service.Factory); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Info("Product dependencies initialized")
|
|
||||||
return deps, cleanup, nil
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,34 +32,3 @@ 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.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
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 {
|
||||||
i.db.CloseConnection()
|
i.db.CloseConnection()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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,19 +21,11 @@ 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, apiPath string) (*DomainProviderImp, error) {
|
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv 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 = protocol + "://" + domain
|
p.domain = os.Getenv(protocol) + "://" + os.Getenv(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
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package architecture
|
|
||||||
|
|
||||||
import (
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep the reusable domain independent of transport and session ownership.
|
|
||||||
func TestSecondFactorDomainBoundary(t *testing.T) {
|
|
||||||
err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
if d.Name() == "http" {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, im := range file.Imports {
|
|
||||||
name, err := strconv.Unquote(im.Path.Value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} {
|
|
||||||
if strings.Contains(name, blocked) {
|
|
||||||
t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package architecture
|
|
||||||
|
|
||||||
import (
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
|
|
||||||
for _, dir := range []string{"../app/runtime", "../app/http"} {
|
|
||||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, im := range file.Imports {
|
|
||||||
name, err := strconv.Unquote(im.Path.Value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
|
|
||||||
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
// Package flow owns signup and account activation, independently of second-factor checks.
|
|
||||||
package flow
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
|
||||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
)
|
|
||||||
|
|
||||||
const Cooldown = time.Minute
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
Accounts account.DB
|
|
||||||
Customers accountservice.AccountService
|
|
||||||
Tokens verification.DB
|
|
||||||
Transactions transaction.Factory
|
|
||||||
Attempts ratelimits.Store
|
|
||||||
Sender notifications.Sender
|
|
||||||
Secret []byte
|
|
||||||
ActivationURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Validate() error {
|
|
||||||
u, err := url.Parse(s.ActivationURL)
|
|
||||||
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
|
|
||||||
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
|
|
||||||
}
|
|
||||||
if len(s.Secret) < 32 {
|
|
||||||
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s *Service) key(parts ...string) string {
|
|
||||||
h := hmac.New(sha256.New, s.Secret)
|
|
||||||
for _, p := range parts {
|
|
||||||
h.Write([]byte(p))
|
|
||||||
h.Write([]byte{0})
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
start := now.Truncate(time.Hour)
|
|
||||||
for _, scope := range []struct {
|
|
||||||
kind, value string
|
|
||||||
limit int
|
|
||||||
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
|
|
||||||
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
|
|
||||||
}
|
|
||||||
if count > scope.limit {
|
|
||||||
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
|
||||||
email, err := customer.NormalizeEmail(a.Login)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
a.Login = email
|
|
||||||
a.Name = strings.TrimSpace(a.Name)
|
|
||||||
if len(a.Name) > 200 || len(a.Password) > 72 {
|
|
||||||
return nil, merrors.InvalidArgument("name or password too long")
|
|
||||||
}
|
|
||||||
if err = s.Limit(ctx, ip, email); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
|
||||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
|
||||||
if lookup != nil {
|
|
||||||
return nil, lookup
|
|
||||||
}
|
|
||||||
// A retry after a queue failure may resume only with the original password.
|
|
||||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
|
||||||
return nil, merrors.DataConflict("email already registered")
|
|
||||||
}
|
|
||||||
a = existing
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if raw != "" {
|
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("token", raw)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
locale := "en"
|
|
||||||
if strings.HasPrefix(a.Locale, "ru") {
|
|
||||||
locale = "ru"
|
|
||||||
}
|
|
||||||
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
|
||||||
} else {
|
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
|
||||||
}
|
|
||||||
return a, err
|
|
||||||
}
|
|
||||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
|
||||||
email, err := customer.NormalizeEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = s.Limit(ctx, ip, email); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
a, err := s.Accounts.GetByEmail(ctx, email)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
|
||||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
|
||||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
|
||||||
}
|
|
||||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
|
||||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
|
||||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
|
||||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
if !result.ExpiresAt.After(time.Now()) {
|
|
||||||
return time.Time{}, verification.ErorrTokenExpired()
|
|
||||||
}
|
|
||||||
locale := "en"
|
|
||||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
|
||||||
locale = "ru"
|
|
||||||
}
|
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("token", result.VerificationToken)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
|
||||||
return result.ExpiresAt, err
|
|
||||||
}
|
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
|
||||||
if len(raw) != 43 {
|
|
||||||
return verification.ErorrTokenNotFound()
|
|
||||||
}
|
|
||||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
|
||||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
a := &customer.Account{}
|
|
||||||
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
|
||||||
return nil, merrors.Unauthorized("account cannot be activated")
|
|
||||||
}
|
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package flow
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tokenStub struct {
|
|
||||||
verification.DB
|
|
||||||
request *verification.Request
|
|
||||||
result verification.CreateResult
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
|
|
||||||
s.request = r
|
|
||||||
return s.result, s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
type senderStub struct {
|
|
||||||
messages []notifications.Message
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
|
|
||||||
s.messages = append(s.messages, m)
|
|
||||||
return s.err
|
|
||||||
}
|
|
||||||
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
|
||||||
expiry := time.Now().UTC().Add(time.Minute)
|
|
||||||
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
|
||||||
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
|
|
||||||
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
|
||||||
a := &customer.Account{}
|
|
||||||
a.ID = bson.NewObjectID()
|
|
||||||
a.Login = "user@example.com"
|
|
||||||
a.Locale = "ru-RU"
|
|
||||||
_, err := service.sendActivation(context.Background(), a, "client-key")
|
|
||||||
require.Error(t, err)
|
|
||||||
key := *tokens.request.IdempotencyKey
|
|
||||||
require.NotEqual(t, "client-key", key)
|
|
||||||
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
|
|
||||||
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
|
|
||||||
sender.err = nil
|
|
||||||
_, err = service.sendActivation(context.Background(), a, "client-key")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
|
||||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
|
||||||
service.Secret = []byte(strings.Repeat("b", 32))
|
|
||||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
|
||||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
|
||||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
|
||||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
|
||||||
require.Len(t, sender.messages, 2)
|
|
||||||
}
|
|
||||||
func TestValidateActivationDestination(t *testing.T) {
|
|
||||||
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
|
|
||||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
|
||||||
require.NoError(t, s.Validate())
|
|
||||||
}
|
|
||||||
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
|
|
||||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
|
||||||
require.Error(t, s.Validate())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -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 := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
router := NewRouter(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
|
||||||
+1
-1
@@ -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"
|
||||||
+2
-2
@@ -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/v1/endpoint"
|
re "git.sendico.io/tech/sxchnge/product/auth/http/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 Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
func NewRouter(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))
|
||||||
File renamed without changes.
+15
-11
@@ -1,17 +1,21 @@
|
|||||||
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/v1/authorized"
|
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
@@ -32,28 +36,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 Install(
|
func NewDispatcher(
|
||||||
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,
|
||||||
token *middleware.TokenConfig,
|
config *middleware.Config,
|
||||||
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.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||||
})
|
})
|
||||||
router.Group(func(r chi.Router) {
|
router.Group(func(r chi.Router) {
|
||||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||||
})
|
})
|
||||||
|
|
||||||
return d
|
return d
|
||||||
+1
-1
@@ -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/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
)
|
)
|
||||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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) {
|
||||||
+1
-1
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
+5
-8
@@ -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"
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||||
"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)
|
||||||
// Preserve passwords exactly as entered.
|
req.Password = strings.TrimSpace(req.Password)
|
||||||
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,9 +225,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -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"
|
||||||
+1
-1
@@ -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/http/v1/request"
|
"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/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
+3
-2
@@ -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/http/v1/request"
|
"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/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -183,10 +183,11 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
|||||||
PrivateKey: []byte("test-secret"),
|
PrivateKey: []byte("test-secret"),
|
||||||
}
|
}
|
||||||
|
|
||||||
router := Install(
|
router := NewRouter(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/",
|
"/",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
|
nil,
|
||||||
rtdb,
|
rtdb,
|
||||||
nil,
|
nil,
|
||||||
chi.NewRouter(),
|
chi.NewRouter(),
|
||||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
File renamed without changes.
+2
-2
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
+2
-1
@@ -47,10 +47,11 @@ 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 Install(
|
return NewRouter(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
|
nil,
|
||||||
rtdb,
|
rtdb,
|
||||||
nil,
|
nil,
|
||||||
chi.NewRouter(),
|
chi.NewRouter(),
|
||||||
+4
-12
@@ -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/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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,14 +19,6 @@ 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,
|
||||||
@@ -40,18 +32,18 @@ func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, sessi
|
|||||||
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 nil, err
|
return response.Internal(pr.logger, pr.service, 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 nil, err
|
return response.Internal(pr.logger, pr.service, 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), nil
|
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
+6
-27
@@ -4,12 +4,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
"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/twofactor"
|
"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"
|
||||||
@@ -17,14 +16,7 @@ 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
|
||||||
@@ -41,17 +33,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 Install(
|
func NewRouter(
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
apiEndpoint string,
|
apiEndpoint string,
|
||||||
db account.DB,
|
db account.DB,
|
||||||
|
vdb verification.DB,
|
||||||
rtdb refreshtokens.DB,
|
rtdb refreshtokens.DB,
|
||||||
attempts ratelimits.Store,
|
attempts ratelimits.Store,
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
config *middleware.TokenConfig,
|
config *middleware.TokenConfig,
|
||||||
signature *middleware.Signature,
|
signature *middleware.Signature,
|
||||||
limits middleware.LoginRateLimits,
|
limits middleware.LoginRateLimits,
|
||||||
flows ...Flows,
|
|
||||||
) *PublicRouter {
|
) *PublicRouter {
|
||||||
l := logger.Named("public")
|
l := logger.Named("public")
|
||||||
hr := PublicRouter{
|
hr := PublicRouter{
|
||||||
@@ -67,20 +59,7 @@ func Install(
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(flows) > 0 {
|
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||||
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)
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
"git.sendico.io/tech/sxchnge/product/auth/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"
|
||||||
)
|
)
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
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, verification.ErrTokenExpired):
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
|
||||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
|
|
||||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
|
|
||||||
case errors.Is(err, merrors.ErrDataConflict):
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
|
|
||||||
case errors.Is(err, merrors.ErrInvalidArg):
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
|
|
||||||
case errors.Is(err, merrors.ErrUnauthorized):
|
|
||||||
return response.Unauthorized(pr.logger, pr.service, "credentials required")
|
|
||||||
default:
|
|
||||||
// Do not include downstream errors: they may contain transport/request details.
|
|
||||||
pr.logger.Warn("Authentication flow dependency failed")
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
|
|
||||||
var req signupRequest
|
|
||||||
if err := decodeFlow(r, &req); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
if req.Key == "" || len(req.Key) > 128 {
|
|
||||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
|
||||||
}
|
|
||||||
a := &customer.Account{Password: req.Password}
|
|
||||||
a.Name = req.Name
|
|
||||||
a.Login = req.Email
|
|
||||||
a.Locale = req.Locale
|
|
||||||
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
|
|
||||||
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Key string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
if err := decodeFlow(r, &req); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
if req.Key == "" || len(req.Key) > 128 {
|
|
||||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
|
||||||
}
|
|
||||||
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
if err := decodeFlow(r, &req); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
pr.logger.Info("Account activation completed")
|
|
||||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
|
||||||
}
|
|
||||||
func challengeScope(raw string) string {
|
|
||||||
h := sha256.Sum256([]byte(raw))
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
|
|
||||||
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
|
|
||||||
return pr.flowError(merrors.InvalidArgument("session identifier required"))
|
|
||||||
}
|
|
||||||
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
|
|
||||||
claims["clientId"] = sessionID.ClientID
|
|
||||||
claims["deviceId"] = sessionID.DeviceID
|
|
||||||
// Independent challenges, even when password requests arrive within one second.
|
|
||||||
claims["nonce"] = randomKey()
|
|
||||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
|
||||||
_, raw, err := ja.Encode(claims)
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
|
||||||
return pr.queueLogin(r, a, token, key, "")
|
|
||||||
}
|
|
||||||
func randomKey() string { return rand.Text() }
|
|
||||||
func loginScope(raw string) factor.Scope {
|
|
||||||
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
|
|
||||||
if pr.factors == nil {
|
|
||||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
|
||||||
}
|
|
||||||
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
|
|
||||||
var challenge factor.Challenge
|
|
||||||
var err error
|
|
||||||
if id == "" {
|
|
||||||
challenge, err = pr.factors.Begin(r.Context(), req)
|
|
||||||
} else {
|
|
||||||
challenge, err = pr.factors.Resend(r.Context(), req, id)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
|
||||||
if len(raw) > 8192 {
|
|
||||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
|
||||||
}
|
|
||||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
|
||||||
t, err := jwtauth.VerifyToken(ja, raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
|
||||||
}
|
|
||||||
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
token, err := authmodel.Claims2Token(claims)
|
|
||||||
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
|
|
||||||
return nil, nil, merrors.Unauthorized("pending login required")
|
|
||||||
}
|
|
||||||
a := &customer.Account{}
|
|
||||||
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if !a.IsActive() || a.IsArchived() {
|
|
||||||
return nil, nil, merrors.Unauthorized("inactive account")
|
|
||||||
}
|
|
||||||
return a, token, nil
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
|
||||||
var req challengeRequest
|
|
||||||
if err := decodeFlow(r, &req); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
if req.ChallengeID == "" {
|
|
||||||
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
|
|
||||||
}
|
|
||||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
|
|
||||||
}
|
|
||||||
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
|
||||||
var req challengeRequest
|
|
||||||
if err := decodeFlow(r, &req); err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
if pr.factors == nil {
|
|
||||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
|
||||||
}
|
|
||||||
scope := loginScope(req.PendingToken)
|
|
||||||
var result http.HandlerFunc
|
|
||||||
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
|
|
||||||
var err error
|
|
||||||
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return pr.flowError(err)
|
|
||||||
}
|
|
||||||
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package public
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
|
||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
|
||||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
|
||||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type capturedMail struct {
|
|
||||||
messages []notifications.Message
|
|
||||||
fail bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|
||||||
m.messages = append(m.messages, r)
|
|
||||||
if m.fail {
|
|
||||||
return merrors.ServiceUnavailable("test queue unavailable")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
|
||||||
uri, err := container.ConnectionString(ctx)
|
|
||||||
require.NoError(t, err)
|
|
||||||
u, err := url.Parse(uri)
|
|
||||||
require.NoError(t, err)
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("directConnection", "true")
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "auth_flow"}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(factory.CloseConnection)
|
|
||||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
mail := &capturedMail{}
|
|
||||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
|
||||||
require.NoError(t, service.Validate())
|
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
|
||||||
require.NoError(t, err)
|
|
||||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
|
||||||
require.NoError(t, err)
|
|
||||||
routes := chi.NewRouter()
|
|
||||||
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, 202, w.Code, w.Body.String())
|
|
||||||
require.Len(t, mail.messages, 1)
|
|
||||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
|
||||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
|
||||||
require.Equal(t, "ru", mail.messages[0].Locale)
|
|
||||||
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
|
|
||||||
require.NoError(t, err)
|
|
||||||
activation := activationURL.Query().Get("token")
|
|
||||||
require.NotEmpty(t, activation)
|
|
||||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, a.IsActive())
|
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, customer.AccountActive, a.Status)
|
|
||||||
w = post("login", login)
|
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
|
||||||
require.Len(t, mail.messages, 2)
|
|
||||||
require.Empty(t, sessions.tokens)
|
|
||||||
var pending struct {
|
|
||||||
Data challengeResponse `json:"data"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
|
|
||||||
require.NotEmpty(t, pending.Data.PendingToken.Token)
|
|
||||||
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
|
|
||||||
code := mail.messages[1].Parameters["code"]
|
|
||||||
require.Len(t, code, 6)
|
|
||||||
wrong := "000000"
|
|
||||||
if code == wrong {
|
|
||||||
wrong = "111111"
|
|
||||||
}
|
|
||||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
|
|
||||||
require.Empty(t, sessions.tokens)
|
|
||||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
|
|
||||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code})
|
|
||||||
require.Equal(t, 200, w.Code, w.Body.String())
|
|
||||||
require.Len(t, sessions.tokens, 1)
|
|
||||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
|
||||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
|
|
||||||
require.Len(t, sessions.tokens, 1)
|
|
||||||
// A queue outage cannot report a successful login challenge or create a session.
|
|
||||||
mail.fail = true
|
|
||||||
require.Equal(t, 503, post("login", login).Code)
|
|
||||||
require.Len(t, sessions.tokens, 1)
|
|
||||||
}
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -9,10 +9,6 @@ database:
|
|||||||
uri_env: SXCHNGE_MONGO_URI
|
uri_env: SXCHNGE_MONGO_URI
|
||||||
database: sxchnge
|
database: sxchnge
|
||||||
api:
|
api:
|
||||||
# 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
|
||||||
@@ -35,6 +31,3 @@ 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,10 +2,6 @@ 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"
|
||||||
@@ -19,6 +15,9 @@ 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 {
|
||||||
@@ -30,7 +29,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:latest", mongodb.WithReplicaSet("rs"))
|
container, err := mongodb.Run(ctx, "mongo:8", 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)
|
||||||
@@ -87,10 +86,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
|||||||
|
|
||||||
deps, err := edgemongo.ResolveDependencies(factory)
|
deps, err := edgemongo.ResolveDependencies(factory)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
raw, rawContext, release, err := deps.Database.Acquire(ctx)
|
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
|
||||||
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)
|
||||||
@@ -120,7 +116,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 := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
|
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.EqualValues(t, 2, count)
|
require.EqualValues(t, 2, count)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,13 +243,6 @@ func NewAccountService(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return FromStores(l, config, stores, dbf.TransactionFactory())
|
return &service{logger: logger, config: config, accountDB: stores.Accounts,
|
||||||
}
|
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
|
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"net/mail"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NormalizeEmail(email string) (string, error) {
|
|
||||||
email = strings.ToLower(strings.TrimSpace(email))
|
|
||||||
a, err := mail.ParseAddress(email)
|
|
||||||
if err != nil || a.Address != email || len(email) > 254 {
|
|
||||||
return "", merrors.InvalidArgument("valid email required")
|
|
||||||
}
|
|
||||||
return email, nil
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package accountdb
|
package accountdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ type AccountDB struct {
|
|||||||
template.DBImp[*customermodel.Account]
|
template.DBImp[*customermodel.Account]
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
|
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) {
|
||||||
return CreateWithCollection(logger, db, mservice.Accounts)
|
return CreateWithCollection(logger, db, mservice.Accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateWithCollection(logger mlogger.Logger, db source.Source, collection string) (*AccountDB, error) {
|
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) {
|
||||||
if normalized := strings.TrimSpace(collection); normalized != "" {
|
if normalized := strings.TrimSpace(collection); normalized != "" {
|
||||||
collection = normalized
|
collection = normalized
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package accountservice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
|
||||||
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
@@ -63,8 +61,3 @@ func NewAccountService(
|
|||||||
) (AccountService, error) {
|
) (AccountService, error) {
|
||||||
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromStores composes a customer service using already initialized storage.
|
|
||||||
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
|
|
||||||
return accountserviceimp.FromStores(logger, config, stores, transactions)
|
|
||||||
}
|
|
||||||
@@ -69,17 +69,13 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
|
|
||||||
git.sendico.io/tech/sxchnge/shared v0.0.0
|
git.sendico.io/tech/sxchnge/shared v0.0.0
|
||||||
github.com/go-chi/chi/v5 v5.3.2
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
@@ -93,10 +89,8 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.9.0
|
go.mongodb.org/mongo-driver/v2 v2.9.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/crypto v0.56.0
|
golang.org/x/crypto v0.56.0
|
||||||
google.golang.org/grpc v1.83.2
|
|
||||||
google.golang.org/protobuf v1.36.12
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
moul.io/chizap v1.0.3
|
moul.io/chizap v1.0.3
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.sendico.io/tech/sxchnge/notifications/interface => ../notifications/interface
|
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||||
+2
-12
@@ -63,8 +63,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -228,8 +226,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -271,14 +267,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
|
||||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
|
||||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
|
||||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// Package notifications adapts the internal notification wire contract to product models.
|
|
||||||
package notifications
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Address string `yaml:"address"`
|
|
||||||
CAFile string `yaml:"ca_file"`
|
|
||||||
ServerName string `yaml:"server_name"`
|
|
||||||
ActivationURL string `yaml:"activation_url"`
|
|
||||||
}
|
|
||||||
type Message struct {
|
|
||||||
Key, UserID, Email, Locale, Template string
|
|
||||||
Parameters map[string]string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
|
||||||
type Sender interface {
|
|
||||||
Send(context.Context, Message) error
|
|
||||||
}
|
|
||||||
type Client struct {
|
|
||||||
conn *grpc.ClientConn
|
|
||||||
rpc pb.NotificationsServiceClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func Open(c Config) (*Client, error) {
|
|
||||||
if c.Address == "" {
|
|
||||||
return nil, merrors.InvalidArgument("notification address is required")
|
|
||||||
}
|
|
||||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName}
|
|
||||||
if c.CAFile != "" {
|
|
||||||
pem, err := os.ReadFile(c.CAFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, "read notification CA")
|
|
||||||
}
|
|
||||||
pool, err := x509.SystemCertPool()
|
|
||||||
if err != nil {
|
|
||||||
pool = x509.NewCertPool()
|
|
||||||
}
|
|
||||||
if !pool.AppendCertsFromPEM(pem) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid notification CA")
|
|
||||||
}
|
|
||||||
tlsConfig.RootCAs = pool
|
|
||||||
}
|
|
||||||
var transport credentials.TransportCredentials = insecure.NewCredentials()
|
|
||||||
if c.CAFile != "" || c.ServerName != "" {
|
|
||||||
transport = credentials.NewTLS(tlsConfig)
|
|
||||||
}
|
|
||||||
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(transport))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, "create notification client")
|
|
||||||
}
|
|
||||||
return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn)}, nil
|
|
||||||
}
|
|
||||||
func (c *Client) Close() error { return c.conn.Close() }
|
|
||||||
func (c *Client) Send(ctx context.Context, m Message) error {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
res, err := c.rpc.Send(ctx, request(m))
|
|
||||||
if err != nil {
|
|
||||||
return merrors.ServiceUnavailableWrap(err, "notification queue unavailable")
|
|
||||||
}
|
|
||||||
if res.GetNotification().GetId() == "" || len(res.GetNotification().GetDeliveries()) != 1 {
|
|
||||||
return merrors.ServiceUnavailable("notification queue returned no receipt")
|
|
||||||
}
|
|
||||||
for _, d := range res.Notification.Deliveries {
|
|
||||||
switch d.State {
|
|
||||||
case pb.DeliveryState_DELIVERY_STATE_FAILED, pb.DeliveryState_DELIVERY_STATE_EXPIRED, pb.DeliveryState_DELIVERY_STATE_UNKNOWN:
|
|
||||||
return merrors.ServiceUnavailable("notification could not be delivered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func request(m Message) *pb.SendRequest {
|
|
||||||
return &pb.SendRequest{IdempotencyKey: m.Key, UserId: m.UserID, TemplateId: m.Template, TemplateVersion: "1", Locale: m.Locale, Parameters: m.Parameters, ExpiresAt: timestamppb.New(m.ExpiresAt), Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: m.Email}}}}}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package notifications
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/pem"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials"
|
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type rpcStub struct {
|
|
||||||
pb.NotificationsServiceClient
|
|
||||||
request *pb.SendRequest
|
|
||||||
auth []string
|
|
||||||
result *pb.SendResponse
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) {
|
|
||||||
s.request = r
|
|
||||||
md, _ := metadata.FromOutgoingContext(ctx)
|
|
||||||
s.auth = md.Get("authorization")
|
|
||||||
return s.result, s.err
|
|
||||||
}
|
|
||||||
func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
|
|
||||||
rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}}
|
|
||||||
client := &Client{rpc: rpc}
|
|
||||||
expiry := time.Now().Add(time.Minute)
|
|
||||||
m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry}
|
|
||||||
require.NoError(t, client.Send(context.Background(), m))
|
|
||||||
require.Empty(t, rpc.auth)
|
|
||||||
require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address)
|
|
||||||
require.Equal(t, "1", rpc.request.TemplateVersion)
|
|
||||||
require.Equal(t, m.Parameters, rpc.request.Parameters)
|
|
||||||
require.WithinDuration(t, expiry, rpc.request.ExpiresAt.AsTime(), time.Nanosecond)
|
|
||||||
rpc.result.Notification.Deliveries[0].State = pb.DeliveryState_DELIVERY_STATE_UNKNOWN
|
|
||||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
|
||||||
rpc.result = nil
|
|
||||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
|
||||||
}
|
|
||||||
func TestOpenRequiresAddressAndValidCA(t *testing.T) {
|
|
||||||
_, err := Open(Config{})
|
|
||||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
|
||||||
_, err = Open(Config{Address: "localhost:8443", CAFile: "missing.pem"})
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tlsRPC struct {
|
|
||||||
pb.UnimplementedNotificationsServiceServer
|
|
||||||
auth chan []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) {
|
|
||||||
md, _ := metadata.FromIncomingContext(ctx)
|
|
||||||
s.auth <- md.Get("authorization")
|
|
||||||
return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil
|
|
||||||
}
|
|
||||||
func TestOpenUsesVerifiedTLSWithoutCallerToken(t *testing.T) {
|
|
||||||
fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
||||||
cert := fixture.TLS.Certificates[0]
|
|
||||||
fixture.Close()
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})))
|
|
||||||
rpc := &tlsRPC{auth: make(chan []string, 1)}
|
|
||||||
pb.RegisterNotificationsServiceServer(server, rpc)
|
|
||||||
go func() { _ = server.Serve(listener) }()
|
|
||||||
t.Cleanup(server.Stop)
|
|
||||||
ca := filepath.Join(t.TempDir(), "ca.pem")
|
|
||||||
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600))
|
|
||||||
client, err := Open(Config{Address: listener.Addr().String(), CAFile: ca})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = client.Close() })
|
|
||||||
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
|
|
||||||
require.Empty(t, <-rpc.auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenUsesPlaintextWithoutCallerToken(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
server := grpc.NewServer()
|
|
||||||
rpc := &tlsRPC{auth: make(chan []string, 1)}
|
|
||||||
pb.RegisterNotificationsServiceServer(server, rpc)
|
|
||||||
go func() { _ = server.Serve(listener) }()
|
|
||||||
t.Cleanup(server.Stop)
|
|
||||||
client, err := Open(Config{Address: listener.Addr().String()})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = client.Close() })
|
|
||||||
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
|
|
||||||
require.Empty(t, <-rpc.auth)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -86,10 +86,8 @@ func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
|
|||||||
|
|
||||||
func cookiePath(r *http.Request) string {
|
func cookiePath(r *http.Request) string {
|
||||||
const accountsSegment = "/accounts"
|
const accountsSegment = "/accounts"
|
||||||
if r != nil {
|
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
|
||||||
if mount, ok := r.Context().Value(scopeKey{}).(string); ok {
|
return path.Join("/", apiEndpoint, accountsSegment)
|
||||||
return path.Join(mount, accountsSegment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if r != nil && r.URL != nil {
|
if r != nil && r.URL != nil {
|
||||||
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
|
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
|
||||||
@@ -121,14 +119,3 @@ func originAllowed(origin string, allowedOrigins []string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type scopeKey struct{}
|
|
||||||
|
|
||||||
// Scope binds cookie creation and deletion to a validated installed API mount.
|
|
||||||
func Scope(mount string) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, mount)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,15 +31,13 @@ func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
|
|||||||
require.Positive(t, cookie.MaxAge)
|
require.Positive(t, cookie.MaxAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetRefreshCookieUsesInstalledScopeOutsideAccountsRouter(t *testing.T) {
|
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
|
||||||
t.Setenv("API_ENDPOINT", "/gateway/v2/")
|
t.Setenv("API_ENDPOINT", "/gateway/v2/")
|
||||||
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
|
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
|
||||||
req.Header.Set(TransportHeader, TransportCookie)
|
req.Header.Set(TransportHeader, TransportCookie)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
Scope("/gateway/v2")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
|
||||||
SetRefreshCookie(w, r, "refresh", time.Now().Add(time.Hour))
|
|
||||||
})).ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
|
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package refreshtokens
|
package refreshtokens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ type RefreshTokenDB struct {
|
|||||||
collection Collections
|
collection Collections
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, db source.Source) (*RefreshTokenDB, error) {
|
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) {
|
||||||
return CreateWithCollections(logger, db, Collections{})
|
return CreateWithCollections(logger, db, Collections{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +38,11 @@ func (c Collections) WithDefaults() Collections {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateWithCollections(logger mlogger.Logger, db source.Source, collections Collections) (*RefreshTokenDB, error) {
|
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) {
|
||||||
collections = collections.WithDefaults()
|
collections = collections.WithDefaults()
|
||||||
p := &RefreshTokenDB{
|
p := &RefreshTokenDB{
|
||||||
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
|
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
|
||||||
clients: repository.CreateRepository(db, collections.Clients),
|
clients: repository.CreateMongoRepository(db, collections.Clients),
|
||||||
collection: collections,
|
collection: collections,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
|
factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -56,7 +55,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.D
|
|||||||
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
|
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
|
||||||
logger := factory.NewLogger(true)
|
logger := factory.NewLogger(true)
|
||||||
|
|
||||||
db, err := refreshtokens.Create(logger, source.Fixed(database))
|
db, err := refreshtokens.Create(logger, database)
|
||||||
require.NoError(t, err, "failed to create refresh tokens db")
|
require.NoError(t, err, "failed to create refresh tokens db")
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
package twofactor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
|
||||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
|
||||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) {
|
|
||||||
if !validScope(req.Scope) {
|
|
||||||
return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope")
|
|
||||||
}
|
|
||||||
return s.begin(ctx, req, nil)
|
|
||||||
}
|
|
||||||
func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) {
|
|
||||||
if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) {
|
|
||||||
return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required")
|
|
||||||
}
|
|
||||||
a, p, version, err := s.profile(ctx, req.Subject)
|
|
||||||
if err != nil {
|
|
||||||
return model.Challenge{}, err
|
|
||||||
}
|
|
||||||
factor, err := s.preferred(p)
|
|
||||||
if err != nil {
|
|
||||||
return model.Challenge{}, err
|
|
||||||
}
|
|
||||||
if enrollment != nil {
|
|
||||||
factor = *enrollment
|
|
||||||
}
|
|
||||||
// Repeating an operation key resolves to the same immutable challenge snapshot.
|
|
||||||
id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24])
|
|
||||||
expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond)
|
|
||||||
if !req.Deadline.IsZero() && req.Deadline.Before(expires) {
|
|
||||||
expires = req.Deadline.UTC().Truncate(time.Millisecond)
|
|
||||||
}
|
|
||||||
if !expires.After(time.Now()) {
|
|
||||||
return model.Challenge{}, verification.ErorrTokenExpired()
|
|
||||||
}
|
|
||||||
locale := "en"
|
|
||||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
|
||||||
locale = "ru"
|
|
||||||
}
|
|
||||||
candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires}
|
|
||||||
candidate.ID = id
|
|
||||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
|
||||||
r, err := s.store.Challenge(tx, id)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
r = candidate
|
|
||||||
if err = s.store.CreateChallenge(tx, r); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version {
|
|
||||||
return nil, merrors.DataConflict("second-factor request changed")
|
|
||||||
}
|
|
||||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return model.Challenge{}, err
|
|
||||||
}
|
|
||||||
return s.deliver(ctx, result)
|
|
||||||
}
|
|
||||||
func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) {
|
|
||||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
|
||||||
r, err := s.record(tx, id, req.Subject, req.Scope)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return model.Challenge{}, err
|
|
||||||
}
|
|
||||||
return s.deliver(ctx, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
type issued struct {
|
|
||||||
record *model.Record
|
|
||||||
delivery model.Delivery
|
|
||||||
resendAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) {
|
|
||||||
if r.VerifiedAt != nil {
|
|
||||||
return issued{}, verification.ErorrTokenAlreadyUsed()
|
|
||||||
}
|
|
||||||
if !r.ExpiresAt.After(time.Now()) {
|
|
||||||
return issued{}, verification.ErorrTokenExpired()
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) {
|
|
||||||
return issued{}, merrors.InvalidArgument("idempotency key required")
|
|
||||||
}
|
|
||||||
if ip == "" {
|
|
||||||
ip = "unresolved"
|
|
||||||
}
|
|
||||||
secretKey := s.key("code", r.ID.Hex(), key)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID).
|
|
||||||
WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey).
|
|
||||||
WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now))
|
|
||||||
token, err := s.tokens.CreateWithResult(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return issued{}, err
|
|
||||||
}
|
|
||||||
if !token.ExpiresAt.After(now) {
|
|
||||||
return issued{}, verification.ErorrTokenExpired()
|
|
||||||
}
|
|
||||||
return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil
|
|
||||||
}
|
|
||||||
func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) {
|
|
||||||
issued, ok := result.(issued)
|
|
||||||
if !ok {
|
|
||||||
return model.Challenge{}, merrors.Internal("invalid second-factor issuance result")
|
|
||||||
}
|
|
||||||
driver := s.drivers[issued.record.Factor.Channel]
|
|
||||||
if driver == nil {
|
|
||||||
return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable")
|
|
||||||
}
|
|
||||||
if err := driver.Send(ctx, issued.delivery); err != nil {
|
|
||||||
s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel())))
|
|
||||||
return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable")
|
|
||||||
}
|
|
||||||
s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel())))
|
|
||||||
return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preferenceScope(factorID string) model.Scope {
|
|
||||||
return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
|
||||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Email struct{ Sender notifications.Sender }
|
|
||||||
|
|
||||||
func (Email) Channel() model.Channel { return model.Email }
|
|
||||||
func (Email) Normalize(address string) (string, error) { return customer.NormalizeEmail(address) }
|
|
||||||
func (Email) Mask(address string) string { return mask.Email(address) }
|
|
||||||
func (d Email) Send(ctx context.Context, m model.Delivery) error {
|
|
||||||
template := "twofactor.code"
|
|
||||||
if m.Scope.Purpose == vmodel.PurposeLogin {
|
|
||||||
template = "auth.login"
|
|
||||||
}
|
|
||||||
return d.Sender.Send(ctx, notifications.Message{Key: m.Key, UserID: m.Subject.Hex(), Email: m.Factor.Address, Locale: m.Locale, Template: template, Parameters: map[string]string{"code": m.Code}, ExpiresAt: m.ExpiresAt})
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
// Package factorhttp exposes authenticated factor settings. Operation scope and
|
|
||||||
// account ownership are resolved on the server; raw proofs never cross HTTP.
|
|
||||||
package factorhttp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
|
||||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
|
||||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
|
||||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
|
||||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Registrar interface {
|
|
||||||
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
|
||||||
}
|
|
||||||
type router struct {
|
|
||||||
service *twofactor.Service
|
|
||||||
logger mlogger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) {
|
|
||||||
h := &router{service: service, logger: logger.Named("twofactor_http")}
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings)
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll)
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment)
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference)
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference)
|
|
||||||
reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend)
|
|
||||||
}
|
|
||||||
func decode(r *http.Request, dst any) error {
|
|
||||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
|
||||||
d.DisallowUnknownFields()
|
|
||||||
if err := d.Decode(dst); err != nil {
|
|
||||||
return merrors.InvalidArgument("invalid request")
|
|
||||||
}
|
|
||||||
var extra any
|
|
||||||
if err := d.Decode(&extra); err != io.EOF {
|
|
||||||
return merrors.InvalidArgument("invalid request")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func requestIP(r *http.Request) string {
|
|
||||||
if ip := ipguard.ClientIP(r); ip != nil {
|
|
||||||
return ip.String()
|
|
||||||
}
|
|
||||||
return "unresolved"
|
|
||||||
}
|
|
||||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
if err == nil {
|
|
||||||
response.Accepted(h.logger, data).ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var handler http.HandlerFunc
|
|
||||||
if throttle, ok := verification.Throttle(err); ok {
|
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds())))))
|
|
||||||
handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code")
|
|
||||||
} else {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, verification.ErrTokenExpired):
|
|
||||||
handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation")
|
|
||||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
|
||||||
handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation")
|
|
||||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData):
|
|
||||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used")
|
|
||||||
case errors.Is(err, merrors.ErrInvalidArg):
|
|
||||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields")
|
|
||||||
case errors.Is(err, merrors.ErrUnauthorized):
|
|
||||||
handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation")
|
|
||||||
case errors.Is(err, merrors.ErrDataConflict):
|
|
||||||
handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists")
|
|
||||||
case errors.Is(err, merrors.ErrNotImplemented):
|
|
||||||
handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable")
|
|
||||||
default:
|
|
||||||
h.logger.Warn("Second-factor settings dependency failed")
|
|
||||||
handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type challengeDTO struct {
|
|
||||||
ID string `json:"challengeId"`
|
|
||||||
Channel model.Channel `json:"channel"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
ResendAt time.Time `json:"resendAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapChallenge(c model.Challenge) challengeDTO {
|
|
||||||
return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt}
|
|
||||||
}
|
|
||||||
|
|
||||||
type choiceDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Channel model.Channel `json:"channel"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
}
|
|
||||||
type settingsDTO struct {
|
|
||||||
Required bool `json:"required"`
|
|
||||||
Preferred string `json:"preferred"`
|
|
||||||
Factors []choiceDTO `json:"factors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSettings(s model.Settings) settingsDTO {
|
|
||||||
result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}}
|
|
||||||
for _, f := range s.Factors {
|
|
||||||
result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
s, err := h.service.Settings(r.Context(), a.ID)
|
|
||||||
return h.result(mapSettings(s), err)
|
|
||||||
}
|
|
||||||
func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
Channel model.Channel `json:"channel"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
Key string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
if err := decode(r, &req); err != nil {
|
|
||||||
return h.result(nil, err)
|
|
||||||
}
|
|
||||||
c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r))
|
|
||||||
return h.result(mapChallenge(c), err)
|
|
||||||
}
|
|
||||||
func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
ID string `json:"challengeId"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
if err := decode(r, &req); err != nil {
|
|
||||||
return h.result(nil, err)
|
|
||||||
}
|
|
||||||
s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code)
|
|
||||||
return h.result(mapSettings(s), err)
|
|
||||||
}
|
|
||||||
func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
ID string `json:"factorId"`
|
|
||||||
Key string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
if err := decode(r, &req); err != nil {
|
|
||||||
return h.result(nil, err)
|
|
||||||
}
|
|
||||||
c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
|
||||||
return h.result(mapChallenge(c), err)
|
|
||||||
}
|
|
||||||
func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
Factor string `json:"factorId"`
|
|
||||||
ID string `json:"challengeId"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
if err := decode(r, &req); err != nil {
|
|
||||||
return h.result(nil, err)
|
|
||||||
}
|
|
||||||
s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code)
|
|
||||||
return h.result(mapSettings(s), err)
|
|
||||||
}
|
|
||||||
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var req struct {
|
|
||||||
ID string `json:"challengeId"`
|
|
||||||
Key string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
if err := decode(r, &req); err != nil {
|
|
||||||
return h.result(nil, err)
|
|
||||||
}
|
|
||||||
c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
|
||||||
return h.result(mapChallenge(c), err)
|
|
||||||
}
|
|
||||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user