New code verification service
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed

This commit is contained in:
Stephan D
2025-11-21 16:41:41 +01:00
parent ef5b3dc1a7
commit e1e4c580e8
72 changed files with 1660 additions and 454 deletions

View File

@@ -7,10 +7,10 @@ replace github.com/tech/sendico/pkg => ../pkg
replace github.com/tech/sendico/chain/gateway => ../chain/gateway
require (
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.20
github.com/aws/aws-sdk-go-v2/credentials v1.18.24
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.0
github.com/aws/aws-sdk-go-v2/credentials v1.19.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -23,7 +23,7 @@ require (
github.com/testcontainers/testcontainers-go v0.33.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3
@@ -40,18 +40,19 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
@@ -104,7 +105,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.3 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
@@ -126,7 +127,7 @@ require (
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect

View File

@@ -6,40 +6,42 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc=
github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0=
github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
github.com/aws/aws-sdk-go-v2/config v1.32.0 h1:T5WWJYnam9SzBLbsVYDu2HscLDe+GU1AUJtfcDAc/vA=
github.com/aws/aws-sdk-go-v2/config v1.32.0/go.mod h1:pSRm/+D3TxBixGMXlgtX4+MPO9VNtEEtiFmNpxksoxw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.0 h1:7zm+ez+qEqLaNsCSRaistkvJRJv8sByDOVuCnyHbP7M=
github.com/aws/aws-sdk-go-v2/credentials v1.19.0/go.mod h1:pHKPblrT7hqFGkNLxqoS3FlGoPrQg4hMIa+4asZzBfs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2 h1:DhdbtDl4FdNlj31+xiRXANxEE+eC7n8JQz+/ilwQ8Uc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0=
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.8 h1:MvlNs/f+9eM0mOjD9JzBUbf5jghyTk3p+O9yHMXX94Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.8/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -196,8 +198,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q=
github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -278,16 +280,16 @@ go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95a
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=

View File

@@ -11,6 +11,7 @@ import (
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)
WSHandler(messageType string, handler ws.HandlerFunc)
Messaging() messaging.Register

View File

@@ -0,0 +1,31 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type pendingLoginResponse struct {
Account accountResponse `json:"account"`
PendingToken TokenData `json:"pendingToken"`
Destination string `json:"destination"`
TTLSeconds int `json:"ttlSeconds"`
}
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, destination string, ttlSeconds int) http.HandlerFunc {
return response.Accepted(
logger,
&pendingLoginResponse{
Account: accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{},
},
PendingToken: *pendingToken,
Destination: destination,
TTLSeconds: ttlSeconds,
},
)
}

View File

@@ -4,9 +4,11 @@ import (
"net/http"
"github.com/tech/sendico/pkg/model"
emodel "github.com/tech/sendico/server/interface/model"
)
type (
HandlerFunc = func(r *http.Request) http.HandlerFunc
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
HandlerFunc = func(r *http.Request) http.HandlerFunc
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc
)

View File

@@ -17,6 +17,7 @@ type AccountToken struct {
Name string
Locale string
Expiration time.Time
Pending bool
}
func createAccountToken(a *model.Account, expiration int) AccountToken {
@@ -26,6 +27,7 @@ func createAccountToken(a *model.Account, expiration int) AccountToken {
Name: a.Name,
Locale: a.Locale,
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
Pending: false,
}
}
@@ -44,6 +46,7 @@ const (
paramNameLocale = "locale"
paramNameLogin = "login"
paramNameExpiration = "exp"
paramNamePending = "pending"
)
func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
@@ -65,6 +68,11 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
return nil, err
}
if pending, ok := claims[paramNamePending]; ok {
if pbool, ok := pending.(bool); ok {
at.Pending = pbool
}
}
if expValue, ok := claims[paramNameExpiration]; ok {
switch exp := expValue.(type) {
case time.Time:
@@ -90,5 +98,20 @@ func Account2Claims(a *model.Account, expiration int) middleware.MapClaims {
paramNameName: t.Name,
paramNameLocale: t.Locale,
paramNameExpiration: int64(t.Expiration.Unix()),
paramNamePending: t.Pending,
}
}
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
t := createAccountToken(a, expirationMinutes/60)
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
t.Pending = true
return middleware.MapClaims{
paramNameID: t.AccountRef.Hex(),
paramNameLogin: t.Login,
paramNameName: t.Name,
paramNameLocale: t.Locale,
paramNameExpiration: t.Expiration.Unix(),
paramNamePending: t.Pending,
}
}

View File

@@ -0,0 +1,11 @@
package confirmation
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/confirmationimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return confirmationimp.CreateAPI(a)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account"
"github.com/tech/sendico/server/interface/services/confirmation"
"github.com/tech/sendico/server/interface/services/invitation"
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
@@ -76,6 +77,7 @@ func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, account.Create)
srvf = append(srvf, confirmation.Create)
srvf = append(srvf, organization.Create)
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)

View File

@@ -45,6 +45,10 @@ func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, met
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) WSHandler(messageType string, handler wsh.HandlerFunc) {
mw.wshandler.InstallHandler(messageType, handler)
}
@@ -133,7 +137,13 @@ func CreateMiddleware(logger mlogger.Logger, db db.Factory, enforcer auth.Enforc
return nil, err
}
p.epdispatcher = routers.NewDispatcher(p.logger, p.router, adb, rtdb, enforcer, config)
cdb, err := db.NewConfirmationsDB()
if err != nil {
p.logger.Error("Failed to create confirmations database", zap.Error(err))
return nil, err
}
p.epdispatcher = routers.NewDispatcher(p.logger, p.router, adb, cdb, rtdb, enforcer, config)
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
return p, nil
}

View File

@@ -37,6 +37,9 @@ func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string,
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
if t.Pending {
return response.Forbidden(ar.logger, ar.service, "confirmation_required", "pending token requires confirmation")
}
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
@@ -54,3 +57,18 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
}
ar.tokenHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.ObjRef("account_ref", t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
return handler(r, &a, t)
}
ar.tokenHandler(service, endpoint, method, hndlr)
}

View File

@@ -8,6 +8,7 @@ import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
@@ -31,7 +32,11 @@ func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, meth
d.protected.AccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
d.protected.PendingAccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, cdb confirmation.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
@@ -40,7 +45,7 @@ func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, rtdb
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, r, &config.Token, &signature)
d.public = rpublic.NewRouter(d.logger, endpoint, db, cdb, rtdb, r, &config.Token, &signature)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)

View File

@@ -18,3 +18,13 @@ func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.
}
return token, err
}
func (er *HttpEndpointRouter) CreatePendingToken(user *model.Account, ttlMinutes int) (sresponse.TokenData, error) {
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
_, res, err := ja.Encode(emodel.PendingAccount2Claims(user, ttlMinutes))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(ttlMinutes) * time.Minute),
}
return token, err
}

View File

@@ -6,14 +6,20 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/internal/server/confirmationimp"
"go.uber.org/zap"
)
const pendingLoginTTLMinutes = 10
func (pr *PublicRouter) logUserIn(ctx context.Context, r *http.Request, req *srequest.Login) http.HandlerFunc {
// Get the account database entry
trimmedLogin := strings.TrimSpace(req.Login)
@@ -35,13 +41,23 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, r *http.Request, req *sre
return response.Unauthorized(pr.logger, pr.service, "password does not match")
}
accessToken, err := pr.imp.CreateAccessToken(account)
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken)
cfg := confirmationimp.DefaultConfig()
_, rec, err := pr.cstore.Create(ctx, account.ID, account.Login, model.ConfirmationTargetLogin, cfg, pr.generateCode)
if err != nil {
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
pr.logger.Info("Login confirmation code issued",
zap.String("destination", pr.maskEmail(account.Login)),
zap.String("account", account.Login))
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {

View File

@@ -2,59 +2,16 @@ package routers
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
rtokens "github.com/tech/sendico/server/internal/api/routers/tokens"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.Internal("failed to generate secure random bytes: " + err.Error())
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func (er *PublicRouter) prepareRefreshToken(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account) (*model.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(er.config.Length)
if err != nil {
er.logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
token := &model.RefreshToken{
AccountBoundBase: model.AccountBoundBase{
AccountRef: account.GetID(),
},
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: *session,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(er.config.Expiration.Refresh) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: r.RemoteAddr,
}
if err = er.rtdb.Create(ctx, token); err != nil {
er.logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}
func (pr *PublicRouter) refreshAndRespondLogin(
ctx context.Context,
r *http.Request,
@@ -62,7 +19,16 @@ func (pr *PublicRouter) refreshAndRespondLogin(
account *model.Account,
accessToken *sresponse.TokenData,
) http.HandlerFunc {
refreshToken, err := pr.prepareRefreshToken(ctx, r, session, account)
refreshToken, err := rtokens.PrepareRefreshToken(
ctx,
r,
pr.rtdb,
pr.config.Length,
pr.config.Expiration.Refresh,
session,
account,
pr.logger,
)
if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))

View File

@@ -1,20 +1,27 @@
package routers
import (
"crypto/rand"
"strings"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
"github.com/tech/sendico/server/internal/server/confirmationimp"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
cdb confirmation.DB
cstore *confirmationimp.ConfirmationStore
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
@@ -26,11 +33,39 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
func (pr *PublicRouter) generateCode() (string, error) {
const digits = "0123456789"
b := make([]byte, confirmationimp.DefaultConfig().CodeLength)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = digits[int(b[i])%len(digits)]
}
return string(b), nil
}
func (pr *PublicRouter) maskEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return email
}
local := parts[0]
if len(local) > 2 {
local = local[:1] + "***" + local[len(local)-1:]
} else {
local = local[:1] + "***"
}
return local + "@" + parts[1]
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, cdb confirmation.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
logger: l,
db: db,
cdb: cdb,
cstore: confirmationimp.NewStore(cdb),
rtdb: rtdb,
config: *config,
signature: *signature,

View File

@@ -12,4 +12,5 @@ type APIRouter interface {
type ProtectedAPIRouter interface {
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
}

View File

@@ -0,0 +1,65 @@
package tokens
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"time"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.Internal("failed to generate secure random bytes: " + err.Error())
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func PrepareRefreshToken(
ctx context.Context,
r *http.Request,
rtdb refreshtokens.DB,
length int,
refreshExpiration int,
session *model.SessionIdentifier,
account *model.Account,
logger mlogger.Logger,
) (*model.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(length)
if err != nil {
logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
token := &model.RefreshToken{
AccountBoundBase: model.AccountBoundBase{
AccountRef: account.GetID(),
},
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: *session,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(refreshExpiration) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: r.RemoteAddr,
}
if err = rtdb.Create(ctx, token); err != nil {
logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}

View File

@@ -0,0 +1,48 @@
package confirmationimp
import (
"encoding/json"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
func (a *ConfirmationAPI) requestCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
target, err := a.parseTarget(req.Target)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
code, rec, err := a.store.Create(r.Context(), account.ID, destination, target, a.config, a.generateCode)
if err != nil {
a.logger.Warn("Failed to create confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
}
a.sendCode(account, target, destination, code)
return response.Accepted(a.logger, confirmationResponse{
TTLSeconds: int(time.Until(rec.ExpiresAt).Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Destination: maskEmail(destination),
})
}

View File

@@ -0,0 +1,56 @@
package confirmationimp
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
func (a *ConfirmationAPI) resendCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation resend request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
target, err := a.parseTarget(req.Target)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
code, rec, err := a.store.Resend(r.Context(), account.ID, destination, target, a.config, a.generateCode)
switch {
case errors.Is(err, errConfirmationNotFound):
return response.NotFound(a.logger, a.Name(), "no_active_code_for_resend")
case errors.Is(err, errConfirmationCooldown):
return response.Forbidden(a.logger, a.Name(), "cooldown_active", "please wait before requesting another code")
case errors.Is(err, errConfirmationResendLimit):
return response.Forbidden(a.logger, a.Name(), "resend_limit_reached", "too many resend attempts")
case err != nil:
a.logger.Warn("Failed to resend confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
}
a.sendCode(account, target, destination, code)
return response.Accepted(a.logger, confirmationResponse{
TTLSeconds: int(time.Until(rec.ExpiresAt).Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Destination: maskEmail(destination),
})
}

View File

@@ -0,0 +1,158 @@
package confirmationimp
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
type Config struct {
CodeLength int
TTL time.Duration
MaxAttempts int
Cooldown time.Duration
ResendLimit int
}
func defaultConfig() Config {
return Config{
CodeLength: 6,
TTL: 10 * time.Minute,
MaxAttempts: 5,
Cooldown: time.Minute,
ResendLimit: 5,
}
}
func DefaultConfig() Config {
return defaultConfig()
}
type ConfirmationAPI struct {
logger mlogger.Logger
config Config
store *ConfirmationStore
rtdb refreshtokens.DB
producer messaging.Producer
tokenConfig middleware.TokenConfig
signature middleware.Signature
}
func (a *ConfirmationAPI) Name() mservice.Type {
return mservice.Confirmations
}
func (a *ConfirmationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*ConfirmationAPI, error) {
cdb, err := a.DBFactory().NewConfirmationsDB()
if err != nil {
return nil, err
}
rtdb, err := a.DBFactory().NewRefreshTokensDB()
if err != nil {
return nil, err
}
p := &ConfirmationAPI{
logger: a.Logger().Named(mservice.Confirmations),
config: defaultConfig(),
store: NewStore(cdb),
rtdb: rtdb,
producer: a.Register().Messaging().Producer(),
tokenConfig: a.Config().Mw.Token,
signature: middleware.SignatureConf(a.Config().Mw),
}
a.Register().PendingAccountHandler(p.Name(), "/confirmations", api.Post, p.requestCode)
a.Register().PendingAccountHandler(p.Name(), "/confirmations/resend", api.Post, p.resendCode)
a.Register().PendingAccountHandler(p.Name(), "/confirmations/verify", api.Post, p.verifyCode)
return p, nil
}
func (a *ConfirmationAPI) generateCode() (string, error) {
const digits = "0123456789"
b := make([]byte, a.config.CodeLength)
_, err := rand.Read(b)
if err != nil {
return "", err
}
for i := range b {
b[i] = digits[int(b[i])%len(digits)]
}
return string(b), nil
}
func (a *ConfirmationAPI) parseTarget(raw string) (model.ConfirmationTarget, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case string(model.ConfirmationTargetLogin):
return model.ConfirmationTargetLogin, nil
case string(model.ConfirmationTargetPayout):
return model.ConfirmationTargetPayout, nil
default:
return "", merrors.InvalidArgument(fmt.Sprintf("unsupported target '%s'", raw), "target")
}
}
func (a *ConfirmationAPI) resolveDestination(reqDest string, account *model.Account) string {
destination := strings.ToLower(strings.TrimSpace(reqDest))
if destination == "" && account != nil {
destination = strings.ToLower(strings.TrimSpace(account.Login))
}
return destination
}
func (a *ConfirmationAPI) sendCode(account *model.Account, target model.ConfirmationTarget, destination, code string) {
a.logger.Info("Confirmation code generated",
zap.String("target", string(target)),
zap.String("destination", maskEmail(destination)),
mzap.ObjRef("account_ref", account.ID))
if err := a.producer.SendMessage(cnotifications.Code(a.Name(), account.ID, destination, target, code)); err != nil {
a.logger.Warn("Failed to send confirmation code notification", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
}
a.logger.Debug("Confirmation code debug dump (do not log in production)", zap.String("code", code))
}
func maskEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return email
}
local := parts[0]
if len(local) > 2 {
local = local[:1] + "***" + local[len(local)-1:]
} else {
local = local[:1] + "***"
}
return local + "@" + parts[1]
}
func (a *ConfirmationAPI) createAccessToken(account *model.Account) (sresponse.TokenData, error) {
ja := jwtauth.New(a.signature.Algorithm, a.signature.PrivateKey, a.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2Claims(account, a.tokenConfig.Expiration.Account))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(a.tokenConfig.Expiration.Account) * time.Hour),
}
return token, err
}

View File

@@ -0,0 +1,181 @@
package confirmationimp
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
"time"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
var (
errConfirmationNotFound = errors.New("confirmation not found or expired")
errConfirmationUsed = errors.New("confirmation already used")
errConfirmationMismatch = errors.New("confirmation code mismatch")
errConfirmationAttemptsExceeded = errors.New("confirmation attempts exceeded")
errConfirmationCooldown = errors.New("confirmation cooldown active")
errConfirmationResendLimit = errors.New("confirmation resend limit reached")
)
type ConfirmationStore struct {
db confirmation.DB
}
func NewStore(db confirmation.DB) *ConfirmationStore {
return &ConfirmationStore{db: db}
}
func (s *ConfirmationStore) Create(
ctx context.Context,
accountRef primitive.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, *model.ConfirmationCode, error) {
if err := s.db.DeleteTuple(ctx, accountRef, destination, target); err != nil && !errors.Is(err, merrors.ErrNoData) {
return "", nil, err
}
code, _, rec, err := s.buildRecord(accountRef, destination, target, cfg, generator)
if err != nil {
return "", nil, err
}
if err := s.db.Create(ctx, rec); err != nil {
return "", nil, err
}
return code, rec, nil
}
func (s *ConfirmationStore) Resend(
ctx context.Context,
accountRef primitive.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, *model.ConfirmationCode, error) {
now := time.Now().UTC()
active, err := s.db.FindActive(ctx, accountRef, destination, target, now.Unix())
if errors.Is(err, merrors.ErrNoData) {
return s.Create(ctx, accountRef, destination, target, cfg, generator)
}
if err != nil {
return "", nil, err
}
if active.ResendCount >= active.ResendLimit {
return "", nil, errConfirmationResendLimit
}
if now.Before(active.CooldownUntil) {
return "", nil, errConfirmationCooldown
}
code, salt, updated, err := s.buildRecord(accountRef, destination, target, cfg, generator)
if err != nil {
return "", nil, err
}
// Preserve attempt counters but bump resend count.
updated.ID = active.ID
updated.CreatedAt = active.CreatedAt
updated.Attempts = active.Attempts
updated.ResendCount = active.ResendCount + 1
updated.Salt = salt
if err := s.db.Update(ctx, updated); err != nil {
return "", nil, err
}
return code, updated, nil
}
func (s *ConfirmationStore) Verify(
ctx context.Context,
accountRef primitive.ObjectID,
destination string,
target model.ConfirmationTarget,
code string,
) error {
now := time.Now().UTC()
rec, err := s.db.FindActive(ctx, accountRef, destination, target, now.Unix())
if errors.Is(err, merrors.ErrNoData) {
return errConfirmationNotFound
}
if err != nil {
return err
}
if rec.Used {
return errConfirmationUsed
}
rec.Attempts++
if rec.Attempts > rec.MaxAttempts {
rec.Used = true
_ = s.db.Update(ctx, rec)
return errConfirmationAttemptsExceeded
}
if subtle.ConstantTimeCompare(rec.CodeHash, hashCode(rec.Salt, code)) != 1 {
_ = s.db.Update(ctx, rec)
return errConfirmationMismatch
}
rec.Used = true
return s.db.Update(ctx, rec)
}
func (s *ConfirmationStore) buildRecord(
accountRef primitive.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, []byte, *model.ConfirmationCode, error) {
code, err := generator()
if err != nil {
return "", nil, nil, err
}
salt, err := newSalt()
if err != nil {
return "", nil, nil, err
}
now := time.Now().UTC()
rec := model.NewConfirmationCode(accountRef)
rec.Destination = destination
rec.Target = target
rec.CodeHash = hashCode(salt, code)
rec.Salt = salt
rec.ExpiresAt = now.Add(cfg.TTL)
rec.MaxAttempts = cfg.MaxAttempts
rec.ResendLimit = cfg.ResendLimit
rec.CooldownUntil = now.Add(cfg.Cooldown)
rec.Used = false
rec.Attempts = 0
rec.ResendCount = 0
rec.CreatedAt = now
rec.UpdatedAt = now
return code, salt, rec, nil
}
func hashCode(salt []byte, code string) []byte {
h := sha256.New()
h.Write(salt)
h.Write([]byte(code))
return h.Sum(nil)
}
func newSalt() ([]byte, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return nil, err
}
return buf, nil
}

View File

@@ -0,0 +1,23 @@
package confirmationimp
import (
"github.com/tech/sendico/pkg/model"
)
type confirmationRequest struct {
Target string `json:"target"`
Destination string `json:"destination,omitempty"`
}
type confirmationVerifyRequest struct {
Target string `json:"target"`
Code string `json:"code"`
Destination string `json:"destination,omitempty"`
SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"`
}
type confirmationResponse struct {
TTLSeconds int `json:"ttl_seconds"`
CooldownSeconds int `json:"cooldown_seconds"`
Destination string `json:"destination"`
}

View File

@@ -0,0 +1,88 @@
package confirmationimp
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
rtokens "github.com/tech/sendico/server/internal/api/routers/tokens"
"go.uber.org/zap"
)
func (a *ConfirmationAPI) verifyCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation verification request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
target, err := a.parseTarget(req.Target)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
if strings.TrimSpace(req.Code) == "" {
return response.BadRequest(a.logger, a.Name(), "missing_code", "confirmation code is required")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
err = a.store.Verify(r.Context(), account.ID, destination, target, strings.TrimSpace(req.Code))
switch {
case errors.Is(err, errConfirmationNotFound):
return response.NotFound(a.logger, a.Name(), "code_not_found_or_expired")
case errors.Is(err, errConfirmationUsed):
return response.Forbidden(a.logger, a.Name(), "code_used", "code has already been used")
case errors.Is(err, errConfirmationAttemptsExceeded):
return response.Forbidden(a.logger, a.Name(), "attempt_limit_reached", "too many failed attempts")
case errors.Is(err, errConfirmationMismatch):
return response.Forbidden(a.logger, a.Name(), "invalid_code", "code does not match")
case err != nil:
a.logger.Warn("Failed to verify confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
}
a.logger.Info("Confirmation code verified", zap.String("target", string(target)), mzap.ObjRef("account_ref", account.ID))
if target == model.ConfirmationTargetLogin {
if req.SessionIdentifier.ClientID == "" || req.SessionIdentifier.DeviceID == "" {
return response.BadRequest(a.logger, a.Name(), "missing_session", "session identifier is required")
}
accessToken, err := a.createAccessToken(account)
if err != nil {
a.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
refreshToken, err := rtokens.PrepareRefreshToken(
r.Context(),
r,
a.rtdb,
a.tokenConfig.Length,
a.tokenConfig.Expiration.Refresh,
&req.SessionIdentifier,
account,
a.logger,
)
if err != nil {
a.logger.Warn("Failed to generate refresh token", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
rt := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(a.logger, account, &accessToken, &rt)
}
return response.Success(a.logger)
}