move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View File

@@ -0,0 +1,157 @@
package apiimp
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/mlogger"
"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/invitation"
"github.com/tech/sendico/server/interface/services/ledger"
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
"github.com/tech/sendico/server/interface/services/payment"
"github.com/tech/sendico/server/interface/services/paymethod"
"github.com/tech/sendico/server/interface/services/permission"
"github.com/tech/sendico/server/interface/services/recipient"
"github.com/tech/sendico/server/interface/services/site"
"github.com/tech/sendico/server/interface/services/verification"
"github.com/tech/sendico/server/interface/services/wallet"
"go.uber.org/zap"
)
type Microservices = []mservice.MicroService
// APIImp represents the structure of the APIImp
type APIImp struct {
logger mlogger.Logger
db db.Factory
domain domainprovider.DomainProvider
config *api.Config
services Microservices
mw *Middleware
}
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
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) Permissions() auth.Provider {
return a.db.Permissions()
}
func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, account.Create)
srvf = append(srvf, verification.Create)
srvf = append(srvf, organization.Create)
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)
srvf = append(srvf, permission.Create)
srvf = append(srvf, site.Create)
srvf = append(srvf, wallet.Create)
srvf = append(srvf, ledger.Create)
srvf = append(srvf, recipient.Create)
srvf = append(srvf, paymethod.Create)
srvf = append(srvf, payment.Create)
for _, v := range srvf {
if err := a.addMicroservice(v); err != nil {
return err
}
}
a.mw.SetStatus(health.SSRunning)
return nil
}
func (a *APIImp) Finish(ctx context.Context) error {
a.mw.SetStatus(health.SSTerminating)
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 lastError
}
func (a *APIImp) Name() string {
return "api"
}
func CreateAPI(logger mlogger.Logger, config *api.Config, db db.Factory, router *chi.Mux, debug bool) (mservice.MicroService, error) {
p := &APIImp{
logger: logger.Named("api"),
config: config,
db: db,
}
var err error
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
p.logger.Error("Failed to initizlize domain provider")
return nil, err
}
p.logger.Info("Domain provider installed")
if p.mw, err = CreateMiddleware(logger, db, p.db.Permissions().Enforcer(), router, config.Mw, debug); err != nil {
p.logger.Error("Failed to create middleware", zap.Error(err))
return nil, err
}
p.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
p.resolveServiceAddressesFromDiscovery()
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}

View File

@@ -0,0 +1,66 @@
package apiimp
import "github.com/tech/sendico/pkg/messaging"
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
type SignatureConf struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type Signature struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type TokenExpiration struct {
Account int `yaml:"account"`
Refresh int `yaml:"refresh"`
}
type TokenConfig struct {
Expiration TokenExpiration `yaml:"expiration_hours"`
Length int `yaml:"length"`
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
Lower bool `yaml:"lower"`
Special bool `yaml:"special"`
MinLength int `yaml:"min_length"`
}
type PasswordConfig struct {
TokenLength int `yaml:"token_length"`
Check PasswordChecks `yaml:"check"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature Signature `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Messaging messaging.Config `yaml:"message_broker"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
}
type MapClaims = map[string]any

View File

@@ -0,0 +1,482 @@
package apiimp
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"go.uber.org/zap"
)
const (
discoveryBootstrapTimeout = 3 * time.Second
discoveryBootstrapSender = "server_bootstrap"
defaultClientDialTimeoutSecs = 5
defaultClientCallTimeoutSecs = 5
)
var (
ledgerDiscoveryServiceNames = []string{
"LEDGER",
string(mservice.Ledger),
}
paymentOrchestratorDiscoveryServiceNames = []string{
"PAYMENTS_ORCHESTRATOR",
string(mservice.PaymentOrchestrator),
}
paymentQuotationDiscoveryServiceNames = []string{
"PAYMENTS_QUOTATION",
"PAYMENTS_QUOTE",
"PAYMENT_QUOTATION",
"payment_quotation",
}
paymentMethodsDiscoveryServiceNames = []string{
"PAYMENTS_METHODS",
"PAYMENT_METHODS",
string(mservice.PaymentMethods),
}
)
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
type serviceSelection struct {
service discovery.ServiceSummary
endpoint discoveryEndpoint
opMatch bool
nameRank int
}
type gatewaySelection struct {
gateway discovery.GatewaySummary
endpoint discoveryEndpoint
networkMatch bool
opMatch bool
}
// resolveServiceAddressesFromDiscovery looks up downstream service addresses once
// during startup and applies them to the runtime config.
func (a *APIImp) resolveServiceAddressesFromDiscovery() {
if a == nil || a.config == nil || a.config.Mw == nil {
return
}
msgCfg := a.config.Mw.Messaging
if msgCfg.Driver == "" {
return
}
logger := a.logger.Named("discovery_bootstrap")
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), &msgCfg)
if err != nil {
logger.Warn("Failed to create discovery bootstrap broker", zap.Error(err))
return
}
client, err := discovery.NewClient(logger, broker, nil, discoveryBootstrapSender)
if err != nil {
logger.Warn("Failed to create discovery bootstrap client", zap.Error(err))
return
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), discoveryBootstrapTimeout)
defer cancel()
lookup, err := client.Lookup(ctx)
if err != nil {
logger.Warn("Failed to fetch discovery registry during startup", zap.Error(err))
return
}
a.resolveChainGatewayAddress(lookup.Gateways)
orchestratorFound, orchestratorEndpoint := a.resolvePaymentOrchestratorAddress(lookup.Services)
a.resolveLedgerAddress(lookup.Services)
a.resolvePaymentQuotationAddress(lookup.Services, orchestratorFound, orchestratorEndpoint)
a.resolvePaymentMethodsAddress(lookup.Services)
}
func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) {
cfg := a.config.ChainGateway
if cfg == nil {
return
}
endpoint, selected, ok := selectGatewayEndpoint(
gateways,
cfg.DefaultAsset.Chain,
[]string{discovery.OperationBalanceRead},
)
if !ok {
return
}
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsChainGateway(cfg)
a.logger.Info("Resolved chain gateway address from discovery",
zap.String("rail", selected.Rail),
zap.String("gateway_id", selected.ID),
zap.String("network", selected.Network),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint(
services,
ledgerDiscoveryServiceNames,
discovery.LedgerServiceOperations(),
)
if !ok {
return
}
cfg := ensureLedgerConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsLedger(cfg)
a.logger.Info("Resolved ledger address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceSummary) (bool, discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentOrchestratorDiscoveryServiceNames,
[]string{discovery.OperationPaymentInitiate},
)
if !ok {
return false, discoveryEndpoint{}
}
cfg := ensurePaymentOrchestratorConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment orchestrator address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
return true, endpoint
}
func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSummary, orchestratorFound bool, orchestratorEndpoint discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentQuotationDiscoveryServiceNames,
[]string{discovery.OperationPaymentQuote},
)
if !ok {
cfg := a.config.PaymentQuotation
if cfg != nil && strings.TrimSpace(cfg.Address) != "" {
return
}
if !orchestratorFound {
return
}
// Fall back to orchestrator endpoint when quotation service is not announced.
endpoint = orchestratorEndpoint
selected = discovery.ServiceSummary{Service: "PAYMENTS_ORCHESTRATOR"}
}
cfg := ensurePaymentQuotationConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment quotation address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentMethodsDiscoveryServiceNames,
[]string{discovery.OperationPaymentMethodsRead},
)
if !ok {
return
}
cfg := ensurePaymentMethodsConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment methods address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []string, requiredOps []string) (discoveryEndpoint, discovery.ServiceSummary, bool) {
selections := make([]serviceSelection, 0)
for _, svc := range services {
if !svc.Healthy {
continue
}
if strings.TrimSpace(svc.InvokeURI) == "" {
continue
}
nameRank, ok := serviceRank(svc.Service, serviceNames)
if !ok {
continue
}
endpoint, err := parseDiscoveryInvokeURI(svc.InvokeURI)
if err != nil {
continue
}
selections = append(selections, serviceSelection{
service: svc,
endpoint: endpoint,
opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps),
nameRank: nameRank,
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.ServiceSummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].nameRank != selections[j].nameRank {
return selections[i].nameRank < selections[j].nameRank
}
if selections[i].service.ID != selections[j].service.ID {
return selections[i].service.ID < selections[j].service.ID
}
return selections[i].service.InstanceID < selections[j].service.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.service, true
}
func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork string, requiredOps []string) (discoveryEndpoint, discovery.GatewaySummary, bool) {
preferredNetwork = strings.TrimSpace(preferredNetwork)
selections := make([]gatewaySelection, 0)
for _, gateway := range gateways {
if !gateway.Healthy {
continue
}
if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) {
continue
}
if strings.TrimSpace(gateway.InvokeURI) == "" {
continue
}
endpoint, err := parseDiscoveryInvokeURI(gateway.InvokeURI)
if err != nil {
continue
}
selections = append(selections, gatewaySelection{
gateway: gateway,
endpoint: endpoint,
networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork),
opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps),
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.GatewaySummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].networkMatch != selections[j].networkMatch {
return selections[i].networkMatch
}
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].gateway.RoutingPriority != selections[j].gateway.RoutingPriority {
return selections[i].gateway.RoutingPriority > selections[j].gateway.RoutingPriority
}
if selections[i].gateway.ID != selections[j].gateway.ID {
return selections[i].gateway.ID < selections[j].gateway.ID
}
return selections[i].gateway.InstanceID < selections[j].gateway.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.gateway, true
}
func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, fmt.Errorf("Invoke uri is empty")
}
// Without a scheme we expect a plain host:port target.
if !strings.Contains(raw, "://") {
if _, _, err := net.SplitHostPort(raw); err != nil {
return discoveryEndpoint{}, fmt.Errorf("Invoke uri must include host:port: %w", err)
}
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
}
parsed, err := url.Parse(raw)
if err != nil {
return discoveryEndpoint{}, err
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("Grpc invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: true,
raw: raw,
}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("Grpcs invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: false,
raw: raw,
}, nil
case "dns", "passthrough":
// gRPC resolver targets such as dns:///service:port.
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
default:
return discoveryEndpoint{}, fmt.Errorf("Unsupported invoke uri scheme: %s", parsed.Scheme)
}
}
func serviceRank(service string, names []string) (int, bool) {
service = strings.TrimSpace(service)
if service == "" {
return 0, false
}
for i, name := range names {
if strings.EqualFold(service, strings.TrimSpace(name)) {
return i, true
}
}
return 0, false
}
func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig {
if cfg == nil {
return nil
}
if cfg.Ledger == nil {
cfg.Ledger = &eapi.LedgerConfig{}
}
return cfg.Ledger
}
func ensurePaymentOrchestratorConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentOrchestrator == nil {
cfg.PaymentOrchestrator = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentOrchestrator
}
func ensurePaymentQuotationConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentQuotation == nil {
cfg.PaymentQuotation = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentQuotation
}
func ensurePaymentMethodsConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentMethods == nil {
cfg.PaymentMethods = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentMethods
}
func ensureTimeoutsLedger(cfg *eapi.LedgerConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsChainGateway(cfg *eapi.ChainGatewayConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsPayment(cfg *eapi.PaymentOrchestratorConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}

View File

@@ -0,0 +1,140 @@
package apiimp
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
)
func TestParseDiscoveryInvokeURI(t *testing.T) {
testCases := []struct {
name string
raw string
address string
insecure bool
wantErr bool
}{
{
name: "host_port",
raw: "ledger:50052",
address: "ledger:50052",
insecure: true,
},
{
name: "grpc_scheme",
raw: "grpc://payments-orchestrator:50062",
address: "payments-orchestrator:50062",
insecure: true,
},
{
name: "grpcs_scheme",
raw: "grpcs://payments-orchestrator:50062",
address: "payments-orchestrator:50062",
insecure: false,
},
{
name: "dns_scheme",
raw: "dns:///ledger:50052",
address: "dns:///ledger:50052",
insecure: true,
},
{
name: "invalid",
raw: "ledger",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
endpoint, err := parseDiscoveryInvokeURI(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tc.raw)
}
return
}
if err != nil {
t.Fatalf("parseDiscoveryInvokeURI(%q) failed: %v", tc.raw, err)
}
if endpoint.address != tc.address {
t.Fatalf("expected address %q, got %q", tc.address, endpoint.address)
}
if endpoint.insecure != tc.insecure {
t.Fatalf("expected insecure %t, got %t", tc.insecure, endpoint.insecure)
}
})
}
}
func TestSelectServiceEndpointPrefersRequiredOperation(t *testing.T) {
services := []discovery.ServiceSummary{
{
ID: "candidate-without-op",
Service: "LEDGER",
Healthy: true,
InvokeURI: "ledger-2:50052",
Ops: []string{"balance.read"},
},
{
ID: "candidate-with-op",
Service: "LEDGER",
Healthy: true,
InvokeURI: "ledger-1:50052",
Ops: []string{"ledger.debit"},
},
}
endpoint, selected, ok := selectServiceEndpoint(services, []string{"LEDGER"}, []string{"ledger.debit"})
if !ok {
t.Fatal("expected service endpoint to be selected")
}
if selected.ID != "candidate-with-op" {
t.Fatalf("expected candidate-with-op, got %s", selected.ID)
}
if endpoint.address != "ledger-1:50052" {
t.Fatalf("expected address ledger-1:50052, got %s", endpoint.address)
}
}
func TestSelectGatewayEndpointPrefersNetworkAndOperation(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "high-priority-no-op",
Rail: "CRYPTO",
Network: "TRON_NILE",
Healthy: true,
InvokeURI: "gw-high:50053",
RoutingPriority: 10,
},
{
ID: "low-priority-with-op",
Rail: "CRYPTO",
Network: "TRON_NILE",
Healthy: true,
InvokeURI: "gw-low:50053",
Ops: []string{"balance.read"},
RoutingPriority: 1,
},
{
ID: "different-network",
Rail: "CRYPTO",
Network: "ARBITRUM_ONE",
Healthy: true,
InvokeURI: "gw-other:50053",
Ops: []string{"balance.read"},
RoutingPriority: 100,
},
}
endpoint, selected, ok := selectGatewayEndpoint(gateways, "TRON_NILE", []string{"balance.read"})
if !ok {
t.Fatal("expected gateway endpoint to be selected")
}
if selected.ID != "low-priority-with-op" {
t.Fatalf("expected low-priority-with-op, got %s", selected.ID)
}
if endpoint.address != "gw-low:50053" {
t.Fatalf("expected address gw-low:50053, got %s", endpoint.address)
}
}

View File

@@ -0,0 +1,149 @@
package apiimp
import (
"os"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
api "github.com/tech/sendico/pkg/api/http"
amr "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
wsh "github.com/tech/sendico/server/interface/api/ws"
"github.com/tech/sendico/server/interface/middleware"
"github.com/tech/sendico/server/internal/api/routers"
mr "github.com/tech/sendico/server/internal/api/routers/metrics"
"github.com/tech/sendico/server/internal/api/ws"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
health amr.Health
metrics mr.Metrics
wshandler ws.Router
messaging amr.Messaging
epdispatcher *routers.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) WSHandler(messageType string, handler wsh.HandlerFunc) {
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Consumer(processor notifications.EnvelopeProcessor) error {
return mw.messaging.Consumer(processor)
}
func (mw *Middleware) Producer() messaging.Producer {
return mw.messaging.Producer()
}
func (mw *Middleware) Messaging() messaging.Register {
return mw
}
func (mw *Middleware) Finish() {
mw.messaging.Finish()
mw.health.Finish()
}
func (mw *Middleware) SetStatus(status health.ServiceStatus) {
mw.health.SetStatus(status)
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for all incoming HTTP requests
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
}))
mw.router.Use(cm.RequestID)
mw.router.Use(cm.RealIP)
if debug {
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
WithReferer: true,
WithUserAgent: true,
}))
}
mw.router.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORS.AllowedOrigins,
AllowedMethods: config.CORS.AllowedMethods,
AllowedHeaders: config.CORS.AllowedHeaders,
ExposedHeaders: config.CORS.ExposedHeaders,
AllowCredentials: config.CORS.AllowCredentials,
MaxAge: config.CORS.MaxAge,
OptionsPassthrough: false,
Debug: debug,
}))
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
mw.logger.Info("Middleware stack installation complete")
}
func CreateMiddleware(logger mlogger.Logger, db db.Factory, enforcer auth.Enforcer, router *chi.Mux, config *middleware.Config, debug bool) (*Middleware, error) {
p := &Middleware{
logger: logger.Named("middleware"),
router: router,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p.installMiddleware(config, debug)
var err error
if p.messaging, err = amr.NewMessagingRouter(p.logger, &config.Messaging); err != nil {
p.logger.Error("Failed to create messaging router", zap.Error(err))
return nil, err
}
if p.health, err = amr.NewHealthRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
adb, err := db.NewAccountDB()
if err != nil {
p.logger.Error("Faild to create account database", zap.Error(err))
return nil, err
}
rtdb, err := db.NewRefreshTokensDB()
if err != nil {
p.logger.Error("Faild to create refresh token management database", zap.Error(err))
return nil, err
}
cdb, err := db.NewVerificationsDB()
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

@@ -0,0 +1,74 @@
package routers
import (
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
api "github.com/tech/sendico/pkg/api/http"
"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/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
hndlr := func(r *http.Request) http.HandlerFunc {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path))
return response.Unauthorized(ar.logger, ar.service, "credentials required")
}
t, err := emodel.Claims2Token(claims)
if err != nil {
ar.logger.Debug("Failed to decode account token", zap.Error(err))
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
}
return handler(r, t)
}
ar.imp.InstallHandler(service, endpoint, method, hndlr)
}
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.Unauthorized(ar.logger, ar.service, "additional verification required")
}
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.AccRef(t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
accessToken, err := ar.imp.CreateAccessToken(&a)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(ar.logger, ar.service, err)
}
return handler(r, &a, &accessToken)
}
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.AccRef(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

@@ -0,0 +1,34 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
)
type AuthorizedRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
l := logger.Named("authorized")
ar := AuthorizedRouter{
logger: l,
db: db,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
return &ar
}

View File

@@ -0,0 +1,55 @@
package routers
import (
"os"
"github.com/go-chi/chi/v5"
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/refreshtokens"
"github.com/tech/sendico/pkg/db/verification"
"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"
rauthorized "github.com/tech/sendico/server/internal/api/routers/authorized"
rpublic "github.com/tech/sendico/server/internal/api/routers/public"
)
type Dispatcher struct {
logger mlogger.Logger
public APIRouter
protected ProtectedAPIRouter
}
func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
d.public.InstallHandler(service, endpoint, method, handler)
}
func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
d.protected.AccountHandler(service, endpoint, method, handler)
}
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, vdb verification.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, r, &config.Token, &signature)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)
})
return d
}

View File

@@ -0,0 +1,36 @@
package routers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
)
type (
RegistratorT = func(chi.Router, string, http.HandlerFunc)
ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc
)
type HttpEndpointRouter struct {
logger mlogger.Logger
apiEndpoint string
router chi.Router
config middleware.TokenConfig
signature middleware.Signature
}
func NewHttpEndpointRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *HttpEndpointRouter {
er := HttpEndpointRouter{
logger: logger.Named("http"),
apiEndpoint: apiEndpoint,
router: router,
signature: *signature,
config: *config,
}
return &er
}

View File

@@ -0,0 +1,50 @@
package routers
import (
"fmt"
"net/http"
"path"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
switch method {
case api.Get:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Get(p, h) }
case api.Post:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Post(p, h) }
case api.Put:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Put(p, h) }
case api.Delete:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Delete(p, h) }
case api.Patch:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Patch(p, h) }
case api.Options:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Options(p, h) }
case api.Head:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Head(p, h) }
default:
}
er.logger.Error("Unknown method provided", zap.String("method", api.HTTPMethod2String(method)))
panic(fmt.Sprintf("Unknown method provided: %d", method))
}
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
return path.Join(er.apiEndpoint, service, handler)
}
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
ep := er.endpoint(service, endpoint)
hm := er.chooseMethod(method)
hndlr := func(w http.ResponseWriter, r *http.Request) {
res := handler(r)
res(w, r)
}
hm(er.router, ep, hndlr)
er.logger.Info("Handler installed", zap.String("endpoint", ep), zap.String("method", api.HTTPMethod2String(method)))
}

View File

@@ -0,0 +1,30 @@
package routers
import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
)
func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) {
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2Claims(user, er.config.Expiration.Account))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
}
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

@@ -0,0 +1,40 @@
package routers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type metricsRouter struct {
logger mlogger.Logger
handler http.Handler
}
func (mr *metricsRouter) Finish() {
mr.logger.Debug("Stopped")
}
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
mr.logger.Debug("Serving metrics request...")
mr.handler.ServeHTTP(w, r)
}
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
mr := metricsRouter{
logger: logger.Named("metrics"),
handler: metrics.Handler(),
}
logger.Debug("Installing Prometheus middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/metrics"
r.Get(ep, mr.handle)
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
})
return &mr
}

View File

@@ -0,0 +1,14 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}

View File

@@ -0,0 +1,67 @@
package routers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mask"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
const pendingLoginTTLMinutes = 10
func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *srequest.Login) http.HandlerFunc {
// Get the account database entry
trimmedLogin := strings.TrimSpace(req.Login)
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
return response.Unauthorized(pr.logger, pr.service, "user not found")
}
if err != nil {
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
return response.Internal(pr.logger, pr.service, err)
}
if !account.IsActive() {
return response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
if !account.MatchPassword(req.Password) {
return response.Unauthorized(pr.logger, pr.service, "password does not match")
}
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
if err != nil {
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
// TODO: add rate check
var req srequest.Login
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Info("Failed to decode login request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Accounts, err)
}
req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password)
if req.Login == "" {
return response.BadRequest(a.logger, mservice.Accounts, "email_missing", "login request has no user name")
}
if req.Password == "" {
return response.BadRequest(a.logger, mservice.Accounts, "password_missing", "login request has no password")
}
return a.logUserIn(r.Context(), r, &req)
}

View File

@@ -0,0 +1,29 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing access token refresh request")
var req srequest.AccessTokenRefresh
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return sresponse.Account(pr.logger, account, token)
}

View File

@@ -0,0 +1,43 @@
package routers
import (
"context"
"net/http"
"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"
rtokens "github.com/tech/sendico/server/internal/api/routers/tokens"
"go.uber.org/zap"
)
func (pr *PublicRouter) refreshAndRespondLogin(
ctx context.Context,
r *http.Request,
session *model.SessionIdentifier,
account *model.Account,
accessToken *sresponse.TokenData,
) http.HandlerFunc {
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))
return response.Internal(pr.logger, pr.service, err)
}
token := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(pr.logger, account, accessToken, &token)
}

View File

@@ -0,0 +1,28 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"go.uber.org/zap"
)
func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing token rotation request...")
var req srequest.TokenRefreshRotate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(r.Context(), r, &req.SessionIdentifier, account, token)
}

View File

@@ -0,0 +1,47 @@
package routers
import (
"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/refreshtokens"
"github.com/tech/sendico/pkg/db/verification"
"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"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
signature middleware.Signature
service mservice.Type
}
func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, vdb verification.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
logger: l,
db: db,
rtdb: rtdb,
config: *config,
signature: *signature,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
return &hr
}

View File

@@ -0,0 +1,59 @@
package routers
import (
"context"
"errors"
"net/http"
"time"
"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/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func validateToken(token string, rt *model.RefreshToken) string {
if rt.AccountRef == nil {
return "missing account reference"
}
if token != rt.RefreshToken {
return "tokens do not match"
}
if rt.ExpiresAt.Before(time.Now()) {
return "token expired"
}
if rt.IsRevoked {
return "token has been revoked"
}
return ""
}
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) {
rt, err := pr.rtdb.GetByCRT(ctx, req)
if errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return nil, nil, err
}
if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 {
pr.logger.Info("Token validation failed", zap.String("reason", reason))
return nil, nil, merrors.Unauthorized(reason)
}
var account model.Account
if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
return nil, nil, merrors.Unauthorized("user not found")
}
accessToken, err := pr.imp.CreateAccessToken(&account)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return nil, nil, err
}
return &account, &accessToken, nil
}

View File

@@ -0,0 +1,16 @@
package routers
import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
)
type APIRouter interface {
InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
}
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,68 @@
package ws
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
ac "github.com/tech/sendico/server/internal/api/config"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ac.WebSocketConfig, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}

View File

@@ -0,0 +1,15 @@
package ws
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}