move api/server to api/edge/bff
This commit is contained in:
157
api/edge/bff/internal/api/api.go
Normal file
157
api/edge/bff/internal/api/api.go
Normal 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
|
||||
}
|
||||
66
api/edge/bff/internal/api/config/config.go
Executable file
66
api/edge/bff/internal/api/config/config.go
Executable 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
|
||||
482
api/edge/bff/internal/api/discovery_resolver.go
Normal file
482
api/edge/bff/internal/api/discovery_resolver.go
Normal 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
|
||||
}
|
||||
}
|
||||
140
api/edge/bff/internal/api/discovery_resolver_test.go
Normal file
140
api/edge/bff/internal/api/discovery_resolver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
149
api/edge/bff/internal/api/middleware.go
Normal file
149
api/edge/bff/internal/api/middleware.go
Normal 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
|
||||
}
|
||||
74
api/edge/bff/internal/api/routers/authorized/handler.go
Normal file
74
api/edge/bff/internal/api/routers/authorized/handler.go
Normal 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)
|
||||
}
|
||||
34
api/edge/bff/internal/api/routers/authorized/router.go
Normal file
34
api/edge/bff/internal/api/routers/authorized/router.go
Normal 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
|
||||
}
|
||||
55
api/edge/bff/internal/api/routers/dispatcher.go
Normal file
55
api/edge/bff/internal/api/routers/dispatcher.go
Normal 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
|
||||
}
|
||||
36
api/edge/bff/internal/api/routers/endpoint/endpoint.go
Normal file
36
api/edge/bff/internal/api/routers/endpoint/endpoint.go
Normal 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
|
||||
}
|
||||
50
api/edge/bff/internal/api/routers/endpoint/install.go
Normal file
50
api/edge/bff/internal/api/routers/endpoint/install.go
Normal 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)))
|
||||
}
|
||||
30
api/edge/bff/internal/api/routers/endpoint/token.go
Normal file
30
api/edge/bff/internal/api/routers/endpoint/token.go
Normal 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
|
||||
}
|
||||
40
api/edge/bff/internal/api/routers/metrics/handler.go
Normal file
40
api/edge/bff/internal/api/routers/metrics/handler.go
Normal 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
|
||||
}
|
||||
14
api/edge/bff/internal/api/routers/metrics/router.go
Normal file
14
api/edge/bff/internal/api/routers/metrics/router.go
Normal 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
|
||||
}
|
||||
67
api/edge/bff/internal/api/routers/public/login.go
Normal file
67
api/edge/bff/internal/api/routers/public/login.go
Normal 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)
|
||||
}
|
||||
29
api/edge/bff/internal/api/routers/public/refresh.go
Normal file
29
api/edge/bff/internal/api/routers/public/refresh.go
Normal 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)
|
||||
}
|
||||
43
api/edge/bff/internal/api/routers/public/respond.go
Normal file
43
api/edge/bff/internal/api/routers/public/respond.go
Normal 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)
|
||||
}
|
||||
28
api/edge/bff/internal/api/routers/public/rotate.go
Normal file
28
api/edge/bff/internal/api/routers/public/rotate.go
Normal 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)
|
||||
}
|
||||
47
api/edge/bff/internal/api/routers/public/router.go
Normal file
47
api/edge/bff/internal/api/routers/public/router.go
Normal 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
|
||||
}
|
||||
59
api/edge/bff/internal/api/routers/public/validate.go
Normal file
59
api/edge/bff/internal/api/routers/public/validate.go
Normal 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
|
||||
}
|
||||
16
api/edge/bff/internal/api/routers/router.go
Normal file
16
api/edge/bff/internal/api/routers/router.go
Normal 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)
|
||||
}
|
||||
65
api/edge/bff/internal/api/routers/tokens/tokens.go
Normal file
65
api/edge/bff/internal/api/routers/tokens/tokens.go
Normal 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
|
||||
}
|
||||
68
api/edge/bff/internal/api/ws/dispimp.go
Normal file
68
api/edge/bff/internal/api/ws/dispimp.go
Normal 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
|
||||
}
|
||||
15
api/edge/bff/internal/api/ws/router.go
Normal file
15
api/edge/bff/internal/api/ws/router.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user