Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

@@ -0,0 +1,172 @@
package httptransport
import (
"net/http"
"os"
"path"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
wshandler ws.Router
epdispatcher *httprouter.Dispatcher
}
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
mw.epdispatcher.Handler(service, endpoint, method, handler)
}
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
if mw.wshandler == nil {
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
return
}
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are
// operational traffic and would otherwise distort request and in-flight SLIs.
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
Skip: func(r *http.Request) bool {
return isObservabilityRequest(mw.apiEndpoint, r.URL.Path)
},
}))
mw.router.Use(cm.RequestID)
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(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := browser.ValidateCSRF(r, config.CORS.AllowedOrigins); err != nil {
mw.logger.Info("Rejected browser request with invalid CSRF context",
zap.String("method", r.Method), zap.String("path", r.URL.Path))
response.Forbidden(mw.logger, mservice.Accounts, "csrf_rejected", "browser request failed CSRF validation").ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
})
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
mw.logger.Info("Middleware stack installation complete")
}
func isObservabilityRequest(apiEndpoint, requestPath string) bool {
healthPath := path.Join("/", apiEndpoint, "health")
metricsPath := path.Join("/", apiEndpoint, "metrics")
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
}
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
enforcer auth.Enforcer,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *edgeapi.AuthStorageConfig,
installWebSocket bool,
debug bool,
) (*Middleware, error) {
if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
}
if err := config.Token.Validate(); err != nil {
return nil, err
}
if err := config.CORS.Validate(); err != nil {
return nil, err
}
loginRateLimits, err := config.ResolveLoginRateLimits()
if err != nil {
logger.Error("Invalid login rate limit configuration", zap.Error(err))
return nil, err
}
p := &Middleware{
logger: logger.Named("middleware"),
router: apiRouter,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p.installMiddleware(config, debug)
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
}
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
if err != nil {
return nil, err
}
p.epdispatcher = httprouter.NewDispatcher(
p.logger,
p.router,
stores.Accounts,
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
enforcer,
config,
loginRateLimits,
)
if installWebSocket {
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
}
return p, nil
}
@@ -0,0 +1,155 @@
package httptransport
import (
"context"
"net/http"
"net/http/httptest"
"testing"
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
"github.com/go-chi/chi/v5"
chimetrics "github.com/go-chi/metrics"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"moul.io/chizap"
)
func TestHTTPCollectorDoesNotGatherHealthOrMetricsRequests(t *testing.T) {
router := chi.NewRouter()
router.Use(chimetrics.Collector(chimetrics.CollectorOpts{
Proto: true,
Skip: func(r *http.Request) bool {
return isObservabilityRequest("/api/v1", r.URL.Path)
},
}))
router.Get("/api/v1/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
router.Handle("/metrics", chimetrics.Handler())
router.Handle("/api/v1/metrics", chimetrics.Handler())
router.Get("/api/v1/accounts", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
beforeHealth := gatheredHTTPRequestCount(t, "GET /api/v1/health")
beforeMetrics := gatheredHTTPRequestCount(t, "GET /metrics")
beforeAPIMetrics := gatheredHTTPRequestCount(t, "GET /api/v1/metrics")
beforeApplication := gatheredHTTPRequestCount(t, "GET /api/v1/accounts")
for _, requestPath := range []string{"/api/v1/health", "/metrics", "/api/v1/metrics", "/api/v1/accounts"} {
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, requestPath, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", requestPath, response.Code)
}
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/health") - beforeHealth; got != 0 {
t.Fatalf("health probe changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /metrics") - beforeMetrics; got != 0 {
t.Fatalf("metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") - beforeAPIMetrics; got != 0 {
t.Fatalf("API metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") - beforeApplication; got != 1 {
t.Fatalf("application request changed http_requests_total by %v, want 1", got)
}
}
func gatheredHTTPRequestCount(t *testing.T, endpoint string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
for _, family := range families {
if family.GetName() != "http_requests_total" {
continue
}
var total float64
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "endpoint" && label.GetValue() == endpoint {
total += metric.GetCounter().GetValue()
}
}
}
return total
}
return 0
}
func TestObservabilityRequestsAreExcludedFromHTTPMetrics(t *testing.T) {
tests := []struct {
name string
apiEndpoint string
requestPath string
want bool
}{
{name: "health under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/health", want: true},
{name: "metrics at root", apiEndpoint: "/api/v1", requestPath: "/metrics", want: true},
{name: "metrics under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/metrics", want: true},
{name: "trailing endpoint slash", apiEndpoint: "/api/v1/", requestPath: "/api/v1/health", want: true},
{name: "application endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/accounts", want: false},
{name: "health suffix is not a probe", apiEndpoint: "/api/v1", requestPath: "/api/v1/health/history", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isObservabilityRequest(tt.apiEndpoint, tt.requestPath); got != tt.want {
t.Fatalf("isObservabilityRequest(%q, %q) = %t, want %t", tt.apiEndpoint, tt.requestPath, got, tt.want)
}
})
}
}
func TestHealthMountedOutsideTraceMiddleware(t *testing.T) {
core, observed := observer.New(zapcore.InfoLevel)
logger := zap.New(core)
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
apiRouter.Use(chizap.New(logger.Named("middleware").Named("http_trace"), &chizap.Opts{}))
apiRouter.Get("/api/v1/accounts", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
healthRouter, err := amr.NewHealthRouter(logger.Named("middleware"), rootRouter, "/api/v1")
if err != nil {
t.Fatalf("failed to create health router: %v", err)
}
healthRouter.SetStatus(health.SSRunning)
rootRouter.Mount("/", apiRouter)
healthReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/health", nil)
healthRes := httptest.NewRecorder()
rootRouter.ServeHTTP(healthRes, healthReq)
if healthRes.Code != http.StatusOK {
t.Fatalf("expected health status %d, got %d", http.StatusOK, healthRes.Code)
}
if got := observed.FilterMessage("Served").Len(); got != 0 {
t.Fatalf("expected no trace logs for /health, got %d", got)
}
apiReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/accounts", nil)
apiRes := httptest.NewRecorder()
rootRouter.ServeHTTP(apiRes, apiReq)
if apiRes.Code != http.StatusOK {
t.Fatalf("expected api status %d, got %d", http.StatusOK, apiRes.Code)
}
entries := observed.FilterMessage("Served").All()
if len(entries) != 1 {
t.Fatalf("expected one trace log for non-health request, got %d", len(entries))
}
if got := entries[0].ContextMap()["path"]; got != "/api/v1/accounts" {
t.Fatalf("expected logged path %q, got %#v", "/api/v1/accounts", got)
}
}
@@ -0,0 +1,154 @@
package authorized
import (
"errors"
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"github.com/go-chi/jwtauth/v5"
"go.uber.org/zap"
)
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
func (ar *AuthorizedRouter) validateClientPolicy(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
clientID := strings.TrimSpace(t.ClientID)
if clientID == "" {
// Legacy tokens without client_id remain valid until expiration.
return nil
}
client, err := ar.rtdb.GetClient(r.Context(), clientID)
if errors.Is(err, merrors.ErrNoData) || client == nil {
ar.logger.Debug("Client not found for access token", zap.String("client_id", clientID))
return response.Unauthorized(ar.logger, ar.service, "client not found")
}
if err != nil {
ar.logger.Warn("Failed to resolve client for access token", zap.Error(err), zap.String("client_id", clientID))
return response.Internal(ar.logger, ar.service, err)
}
if client.IsRevoked {
return response.Unauthorized(ar.logger, ar.service, "client has been revoked")
}
if client.AccountRef != nil && *client.AccountRef != t.AccountRef {
return response.Unauthorized(ar.logger, ar.service, "client account mismatch")
}
clientIP := ipguard.ClientIP(r)
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
if err != nil {
ar.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
return response.Forbidden(ar.logger, ar.service, "client_ip_policy_invalid", "client ip policy is invalid")
}
if !allowed {
rawIP := ""
if clientIP != nil {
rawIP = clientIP.String()
}
ar.logger.Info("Client IP policy denied authorized request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
return response.Forbidden(ar.logger, ar.service, "ip_not_allowed", "request ip is not allowed for this client")
}
return nil
}
func (ar *AuthorizedRouter) validateSession(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
if t.Pending {
return nil
}
if strings.TrimSpace(t.ClientID) == "" || strings.TrimSpace(t.DeviceID) == "" || strings.TrimSpace(t.SessionVersion) == "" {
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.MissingIdentity)
ar.logger.Debug("Access token has no session identifier", mzap.AccRef(t.AccountRef))
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
}
if _, err := ar.rtdb.GetSession(r.Context(), t.AccountRef, &t.SessionIdentifier); err != nil {
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) {
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.AlreadyInactive)
ar.logger.Debug("Access token session is not active", mzap.AccRef(t.AccountRef),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
return response.Unauthorized(ar.logger, ar.service, "session is not active")
}
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.StorageError)
ar.logger.Warn("Failed to validate access token session", zap.Error(err), mzap.AccRef(t.AccountRef),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
return response.Internal(ar.logger, ar.service, err)
}
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.Active)
return nil
}
func (ar *AuthorizedRouter) tokenHandlerWithSessionCheck(service mservice.Type, endpoint string, method api.HTTPMethod, checkSession bool, 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")
}
if h := ar.validateClientPolicy(r, t); h != nil {
return h
}
if checkSession {
if h := ar.validateSession(r, t); h != nil {
return h
}
}
return handler(r, t)
}
ar.imp.InstallHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
ar.tokenHandlerWithSessionCheck(service, endpoint, method, true, handler)
}
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.CreateAccessTokenForSession(&a, t.SessionIdentifier)
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)
}
@@ -0,0 +1,61 @@
package authorized
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"go.uber.org/zap"
)
type AuthorizedRouter struct {
logger mlogger.Logger
db account.DB
rtdb refreshtokens.DB
imp *re.HttpEndpointRouter
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.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,
rtdb: rtdb,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
ar.tokenHandlerWithSessionCheck(mservice.Accounts, "/logout/all", api.Post, false, ar.logoutAll)
return &ar
}
func (ar *AuthorizedRouter) logoutAll(r *http.Request, token *emodel.AccountToken) http.HandlerFunc {
if token.ClientID == "" || token.DeviceID == "" || token.SessionVersion == "" {
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.MissingIdentity)
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
}
if err := ar.rtdb.RevokeAll(r.Context(), token.AccountRef, ""); err != nil {
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.StorageError)
ar.logger.Warn("Failed to revoke all account sessions", zap.Error(err), mzap.AccRef(token.AccountRef))
return response.Internal(ar.logger, ar.service, err)
}
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.Succeeded)
ar.logger.Info("All account sessions revoked", mzap.AccRef(token.AccountRef))
return response.Success(ar.logger)
}
@@ -0,0 +1,113 @@
package authorized
import (
"context"
"net/http"
"net/http/httptest"
"testing"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type sessionDB struct {
refreshtokens.DB
token *model.RefreshToken
err error
revokeAllID string
}
func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *model.SessionIdentifier) (*model.RefreshToken, error) {
return db.token, db.err
}
func (db *sessionDB) RevokeAll(_ context.Context, _ bson.ObjectID, deviceID string) error {
db.revokeAllID = deviceID
return db.err
}
func TestValidateSessionFailsClosed(t *testing.T) {
accountRef := bson.NewObjectID()
token := &emodel.AccountToken{
AccountRef: accountRef,
SessionIdentifier: model.SessionIdentifier{
ClientID: "web",
DeviceID: "device-a",
SessionVersion: "version-a",
},
}
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/accounts", nil)
tests := []struct {
name string
db *sessionDB
mutate func(*emodel.AccountToken)
wantStatus int
}{
{name: "active", db: &sessionDB{token: &model.RefreshToken{}}, wantStatus: 0},
{name: "revoked", db: &sessionDB{err: merrors.ErrNoData}, wantStatus: http.StatusUnauthorized},
{name: "storage unavailable", db: &sessionDB{err: merrors.Internal("database unavailable")}, wantStatus: http.StatusInternalServerError},
{
name: "missing version",
db: &sessionDB{},
mutate: func(token *emodel.AccountToken) {
token.SessionVersion = ""
},
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
candidate := *token
if tt.mutate != nil {
tt.mutate(&candidate)
}
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: tt.db, service: mservice.Accounts}
handler := router.validateSession(request, &candidate)
if tt.wantStatus == 0 {
if handler != nil {
t.Fatal("active session was rejected")
}
return
}
if handler == nil {
t.Fatal("invalid session was accepted")
}
response := httptest.NewRecorder()
handler(response, request)
if response.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus)
}
})
}
}
func TestLogoutAllRevokesEveryDevice(t *testing.T) {
db := &sessionDB{}
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
token := &emodel.AccountToken{
AccountRef: bson.NewObjectID(),
SessionIdentifier: model.SessionIdentifier{
ClientID: "web",
DeviceID: "device-a",
SessionVersion: "version-a",
},
}
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout/all", nil)
response := httptest.NewRecorder()
router.logoutAll(request, token)(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if db.revokeAllID != "" {
t.Fatalf("device exclusion = %q, want all devices", db.revokeAllID)
}
}
@@ -0,0 +1,66 @@
package router
import (
"os"
"github.com/go-chi/chi/v5"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
rauthorized "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/authorized"
rpublic "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/public"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
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,
attempts ratelimits.Store,
enforcer auth.Enforcer,
config *middleware.Config,
loginRateLimits middleware.LoginRateLimits,
) *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, attempts, r, &config.Token, &signature, loginRateLimits)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
})
return d
}
@@ -0,0 +1,36 @@
package endpoint
import (
"context"
"net/http"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/go-chi/chi/v5"
)
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
}
@@ -0,0 +1,55 @@
package endpoint
import (
"net/http"
"path"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
"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:
return nil
}
}
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)
if hm == nil {
er.logger.Warn("Handler install skipped because method is unsupported",
zap.String("endpoint", ep),
zap.String("method", api.HTTPMethod2String(method)),
)
return
}
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)))
}
@@ -0,0 +1,30 @@
package endpoint
import (
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/go-chi/jwtauth/v5"
)
func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *model.Account, session model.SessionIdentifier) (sresponse.TokenData, error) {
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2ClaimsForSession(user, er.config.Expiration.Account, session))
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
}
@@ -0,0 +1,40 @@
package metrics
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"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
}
@@ -0,0 +1,14 @@
package metrics
import (
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}
@@ -0,0 +1,249 @@
package public
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
clientauth "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/client"
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask"
"go.uber.org/zap"
)
const pendingLoginTTLMinutes = 10
const apiLoginGrantType = "password"
const apiLoginClientAuthMethod = "client_secret_post"
const dummyPasswordHash = "$2y$10$pmvNFN5zyooml6Oqh.cGFenBF/indHDE.dyLwjNJW1.Tk2Suvdvve"
func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, bool, 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) || (err == nil && account == nil) {
(&model.Account{Password: dummyPasswordHash}).MatchPassword(req.Password)
pr.logger.Debug("Login rejected: invalid credentials")
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
}
if err != nil {
pr.logger.Warn("Failed to query user during login", zap.Error(err))
return nil, false, response.Internal(pr.logger, pr.service, err)
}
if !account.MatchPassword(req.Password) {
pr.logger.Debug("Login rejected: invalid credentials")
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
}
if !account.IsActive() {
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
return account, false, nil
}
func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc {
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 hasGrantType(grants []string, target string) bool {
for _, grant := range grants {
if strings.EqualFold(strings.TrimSpace(grant), target) {
return true
}
}
return false
}
func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc {
if client == nil {
pr.logger.Debug("Client not found, rejecting authorization", zap.String("client_id", clientID))
return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error())
}
clientIP := ipguard.ClientIP(r)
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
if err != nil {
pr.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
return response.Forbidden(pr.logger, pr.service, "client_ip_policy_invalid", "client ip policy is invalid")
}
if !allowed {
rawIP := ""
if clientIP != nil {
rawIP = clientIP.String()
}
pr.logger.Info("Client IP policy denied request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
}
return nil
}
func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*model.Client, http.HandlerFunc) {
client, err := clientauth.Resolve(ctx, pr.rtdb, clientID)
if errors.Is(err, clientauth.ErrUnknownClientID) {
pr.logger.Debug("Login rejected: unknown client id", zap.String("client_id", clientID))
return nil, response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error())
}
if err != nil {
pr.logger.Warn("Login rejected: failed to load client", zap.Error(err), zap.String("client_id", clientID))
return nil, response.Internal(pr.logger, pr.service, err)
}
return client, nil
}
func (pr *PublicRouter) validateClientAccountBinding(client *model.Client, account *model.Account) http.HandlerFunc {
if client == nil || client.AccountRef == nil {
return nil
}
accountRef := account.GetID()
if accountRef == nil || *client.AccountRef != *accountRef {
return response.Forbidden(pr.logger, pr.service, "client_account_mismatch", "client is bound to another account")
}
return nil
}
func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, client *model.Client) http.HandlerFunc {
if client == nil {
return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error())
}
if client.IsRevoked {
return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked")
}
if h := pr.validateClientIPPolicy(r, clientID, client); h != nil {
return h
}
return nil
}
func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
client, h := pr.loadClient(ctx, req.ClientID)
if h != nil {
return h
}
if h := pr.validateClientState(r, req.ClientID, client); h != nil {
return h
}
if !hasGrantType(client.GrantTypes, apiLoginGrantType) {
return response.Forbidden(pr.logger, pr.service, "client_grant_not_allowed", "client does not allow password grant")
}
method := strings.ToLower(strings.TrimSpace(client.TokenEndpointAuthMethod))
if method == "" {
method = apiLoginClientAuthMethod
}
if method != apiLoginClientAuthMethod {
return response.Forbidden(pr.logger, pr.service, "client_auth_method_unsupported", "unsupported client auth method")
}
storedSecret := strings.TrimSpace(client.ClientSecret)
if storedSecret == "" {
return response.Forbidden(pr.logger, pr.service, "client_secret_missing", "client secret is not configured")
}
if subtle.ConstantTimeCompare([]byte(storedSecret), []byte(req.ClientSecret)) != 1 {
pr.logger.Debug("API login rejected: invalid client secret", zap.String("client_id", req.ClientID))
return response.Unauthorized(pr.logger, pr.service, "invalid client secret")
}
if h := pr.validateClientAccountBinding(client, account); h != nil {
return h
}
return nil
}
func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, clientID string) (*model.Client, http.HandlerFunc) {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return nil, nil
}
client, h := pr.loadClient(ctx, clientID)
if h != nil {
return nil, h
}
if h := pr.validateClientState(r, clientID, client); h != nil {
return nil, h
}
return client, nil
}
func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
if req.ClientID == "" || req.DeviceID == "" {
return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required")
}
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account)
}
func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.HandlerFunc) {
var req srequest.Login
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Info("Failed to decode login request", zap.Error(err))
return nil, response.BadPayload(logger, mservice.Accounts, err)
}
req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password)
req.ClientID = strings.TrimSpace(req.ClientID)
req.DeviceID = strings.TrimSpace(req.DeviceID)
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
if req.Login == "" {
return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name")
}
if req.Password == "" {
return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password")
}
return &req, nil
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
req, h := decodeLogin(r, a.logger)
if h != nil {
return h
}
client, h := a.resolveLoginClient(r.Context(), r, req.ClientID)
if h != nil {
return h
}
account, h := a.authenticateRateLimited(r, req)
if h != nil {
return h
}
if h = a.validateClientAccountBinding(client, account); h != nil {
return h
}
return a.respondPendingLogin(account)
}
func (a *PublicRouter) apiLogin(r *http.Request) http.HandlerFunc {
req, h := decodeLogin(r, a.logger)
if h != nil {
return h
}
if req.ClientID == "" {
return response.BadRequest(a.logger, mservice.Accounts, "client_id_missing", "clientId is required")
}
if req.ClientSecret == "" {
return response.BadRequest(a.logger, mservice.Accounts, "client_secret_missing", "clientSecret is required")
}
account, h := a.authenticateRateLimited(r, req)
if h != nil {
return h
}
if h = a.validateAPIClient(r.Context(), r, req, account); h != nil {
return h
}
return a.respondAPILogin(r.Context(), r, req, account)
}
@@ -0,0 +1,179 @@
package public
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.uber.org/zap"
)
const (
loginScopeAccount = "account"
loginScopeIP = "ip"
)
var loginRateLimitDecisions = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "sendico_login_rate_limit_decisions_total",
Help: "Login rate-limit decisions grouped by scope kind and outcome.",
}, []string{"scope_kind", "outcome"})
type loginRateScope struct {
kind string
hash string
limit int
windowStart time.Time
expiresAt time.Time
}
func (pr *PublicRouter) authenticateRateLimited(
r *http.Request,
req *srequest.Login,
) (*model.Account, http.HandlerFunc) {
if pr.attempts == nil {
account, _, h := pr.authenticateAccount(r.Context(), req)
return account, h
}
clock := pr.now
if clock == nil {
clock = time.Now
}
now := clock().UTC()
scopes := pr.loginRateScopes(r, req.Login, now)
if h := pr.checkLoginRateLimits(r.Context(), scopes, now); h != nil {
return nil, h
}
account, credentialFailure, h := pr.authenticateAccount(r.Context(), req)
if !credentialFailure {
for _, scope := range scopes {
loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc()
}
return account, h
}
limited, retryAfter, failureHandler := pr.recordLoginFailure(r.Context(), scopes, now)
if failureHandler != nil {
return nil, failureHandler
}
if limited {
return nil, pr.loginRateLimited(retryAfter)
}
return nil, h
}
func (pr *PublicRouter) loginRateScopes(r *http.Request, login string, now time.Time) []loginRateScope {
normalizedLogin := strings.ToLower(strings.TrimSpace(login))
clientIP := "unresolved"
if resolved := ipguard.ClientIP(r); resolved != nil {
clientIP = resolved.String()
}
return []loginRateScope{
newLoginRateScope(loginScopeAccount, normalizedLogin, pr.limits.PerAccount, now),
newLoginRateScope(loginScopeIP, clientIP, pr.limits.PerIP, now),
}
}
func newLoginRateScope(kind, value string, limit middleware.LoginRateLimitWindow, now time.Time) loginRateScope {
windowStart := now.Truncate(limit.Window)
return loginRateScope{
kind: kind,
hash: ratelimits.HashScope(kind, value),
limit: limit.Limit,
windowStart: windowStart,
expiresAt: windowStart.Add(limit.Window),
}
}
func (pr *PublicRouter) checkLoginRateLimits(
ctx context.Context,
scopes []loginRateScope,
now time.Time,
) http.HandlerFunc {
for _, scope := range scopes {
count, err := pr.attempts.Current(ctx, scope.kind, scope.hash, scope.windowStart)
if err != nil {
return pr.loginRateLimitStorageFailure(scope.kind, err)
}
if count >= scope.limit {
loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc()
retryAfter := retryAfterDuration(now, scope.expiresAt)
pr.logger.Info("Denied login request at rate limit",
zap.String("scope_kind", scope.kind),
zap.Int64("retry_after_seconds", retryAfterSeconds(retryAfter)))
return pr.loginRateLimited(retryAfter)
}
}
return nil
}
func (pr *PublicRouter) recordLoginFailure(
ctx context.Context,
scopes []loginRateScope,
now time.Time,
) (bool, time.Duration, http.HandlerFunc) {
limited := false
var retryAfter time.Duration
for _, scope := range scopes {
count, err := pr.attempts.Increment(ctx, scope.kind, scope.hash, scope.windowStart, scope.expiresAt)
if err != nil {
return false, 0, pr.loginRateLimitStorageFailure(scope.kind, err)
}
if count >= scope.limit {
limited = true
loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc()
scopeRetry := retryAfterDuration(now, scope.expiresAt)
if retryAfter == 0 || scopeRetry < retryAfter {
retryAfter = scopeRetry
}
pr.logger.Info("Login failure reached rate limit",
zap.String("scope_kind", scope.kind),
zap.Int64("retry_after_seconds", retryAfterSeconds(scopeRetry)))
continue
}
loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc()
}
return limited, retryAfter, nil
}
func (pr *PublicRouter) loginRateLimitStorageFailure(scopeKind string, cause error) http.HandlerFunc {
loginRateLimitDecisions.WithLabelValues(scopeKind, "error").Inc()
pr.logger.Warn("Login protection storage failed", zap.Error(cause), zap.String("scope_kind", scopeKind))
err := merrors.OpaqueWrap(merrors.ErrServiceUnavailable, cause, "login protection is temporarily unavailable")
return response.Auto(pr.logger, pr.service, err)
}
func (pr *PublicRouter) loginRateLimited(retryAfter time.Duration) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", strconv.FormatInt(retryAfterSeconds(retryAfter), 10))
response.TooManyRequests(pr.logger, pr.service, "too many login attempts; try again later")(w, r)
}
}
func retryAfterDuration(now, expiresAt time.Time) time.Duration {
retryAfter := expiresAt.Sub(now)
if retryAfter <= 0 {
return time.Second
}
return retryAfter
}
func retryAfterSeconds(retryAfter time.Duration) int64 {
seconds := int64((retryAfter + time.Second - 1) / time.Second)
if seconds < 1 {
return 1
}
return seconds
}
@@ -0,0 +1,316 @@
package public
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.uber.org/zap"
)
type fakeLoginAttemptStore struct {
mu sync.Mutex
counts map[string]int
currentErr error
incrementErr error
}
func newFakeLoginAttemptStore() *fakeLoginAttemptStore {
return &fakeLoginAttemptStore{counts: make(map[string]int)}
}
func (s *fakeLoginAttemptStore) Current(_ context.Context, kind, hash string, start time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.currentErr != nil {
return 0, s.currentErr
}
return s.counts[attemptKey(kind, hash, start)], nil
}
func (s *fakeLoginAttemptStore) Increment(
_ context.Context,
kind, hash string,
start, _ time.Time,
) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.incrementErr != nil {
return 0, s.incrementErr
}
key := attemptKey(kind, hash, start)
s.counts[key]++
return s.counts[key], nil
}
func attemptKey(kind, hash string, start time.Time) string {
return kind + ":" + hash + ":" + start.UTC().Format(time.RFC3339Nano)
}
func testLoginLimits(accountLimit, ipLimit int, window time.Duration) middleware.LoginRateLimits {
return middleware.LoginRateLimits{
PerAccount: middleware.LoginRateLimitWindow{Limit: accountLimit, Window: window},
PerIP: middleware.LoginRateLimitWindow{Limit: ipLimit, Window: window},
}
}
func testRateLimitedRouter(
db *testAccountDB,
store *fakeLoginAttemptStore,
limits middleware.LoginRateLimits,
now *time.Time,
) *PublicRouter {
return &PublicRouter{
logger: zap.NewNop(),
db: db,
service: mservice.Accounts,
attempts: store,
limits: limits,
now: func() time.Time {
return *now
},
}
}
func loginRequest(login, password, remoteAddr string) (*http.Request, *srequest.Login) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil)
req.RemoteAddr = remoteAddr
return req, &srequest.Login{LoginData: model.LoginData{
UserDataBase: model.UserDataBase{Login: login},
Password: password,
}}
}
func executeLoginHandler(t *testing.T, h http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
if h == nil {
t.Fatal("expected response handler")
}
recorder := httptest.NewRecorder()
h(recorder, req)
return recorder
}
func TestLoginRateLimitNormalizesAccountAndBoundsDistributedAttempts(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
store := newFakeLoginAttemptStore()
db := &testAccountDB{result: testActiveAccount(t)}
router := testRateLimitedRouter(db, store, testLoginLimits(3, 100, 10*time.Minute), &now)
attempts := []struct {
login string
remote string
status int
}{
{login: " User@Example.com ", remote: "192.0.2.1:1000", status: http.StatusUnauthorized},
{login: "user@example.com", remote: "192.0.2.2:1000", status: http.StatusUnauthorized},
{login: "USER@EXAMPLE.COM", remote: "192.0.2.3:1000", status: http.StatusTooManyRequests},
}
for _, attempt := range attempts {
req, login := loginRequest(attempt.login, "wrong password", attempt.remote)
_, h := router.authenticateRateLimited(req, login)
recorder := executeLoginHandler(t, h, req)
if recorder.Code != attempt.status {
t.Fatalf("login %q status: got %d want %d", attempt.login, recorder.Code, attempt.status)
}
}
req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.4:1000")
_, h := router.authenticateRateLimited(req, login)
recorder := executeLoginHandler(t, h, req)
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "600" {
t.Fatalf("blocked recovery mismatch: status=%d retry=%q", recorder.Code, recorder.Header().Get("Retry-After"))
}
now = now.Add(10 * time.Minute)
account, h := router.authenticateRateLimited(req, login)
if h != nil || account == nil {
t.Fatalf("expected legitimate login after window expiry, account=%v handler=%v", account, h)
}
}
func TestLoginRateLimitBoundsOneSourceAcrossAccounts(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
store := newFakeLoginAttemptStore()
router := testRateLimitedRouter(
&testAccountDB{err: merrors.ErrNoData},
store,
testLoginLimits(100, 3, 15*time.Minute),
&now,
)
for i, loginValue := range []string{"one@example.com", "two@example.com", "three@example.com"} {
req, login := loginRequest(loginValue, "wrong password", "198.51.100.10:9000")
_, h := router.authenticateRateLimited(req, login)
recorder := executeLoginHandler(t, h, req)
want := http.StatusUnauthorized
if i == 2 {
want = http.StatusTooManyRequests
}
if recorder.Code != want {
t.Fatalf("attempt %d status: got %d want %d", i+1, recorder.Code, want)
}
}
}
func TestLoginEndpointReturnsStableRateLimitResponse(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
router := testRateLimitedRouter(
&testAccountDB{err: merrors.ErrNoData},
newFakeLoginAttemptStore(),
testLoginLimits(2, 50, 15*time.Minute),
&now,
)
body := `{"login":{"login":"missing@example.com","password":"wrong password"}}`
for attempt := 1; attempt <= 2; attempt++ {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(body))
req.RemoteAddr = "198.51.100.20:9000"
recorder := httptest.NewRecorder()
router.login(req)(recorder, req)
want := http.StatusUnauthorized
if attempt == 2 {
want = http.StatusTooManyRequests
}
if recorder.Code != want {
t.Fatalf("attempt %d status: got %d want %d", attempt, recorder.Code, want)
}
if attempt == 2 {
if recorder.Header().Get("Retry-After") != "900" {
t.Fatalf("unexpected Retry-After: %q", recorder.Header().Get("Retry-After"))
}
if !strings.Contains(recorder.Body.String(), `"error":"too_many_requests"`) {
t.Fatalf("unexpected 429 body: %s", recorder.Body.String())
}
}
}
}
func TestInvalidCredentialsDoNotDiscloseAccountExistence(t *testing.T) {
request := &srequest.Login{LoginData: model.LoginData{
UserDataBase: model.UserDataBase{Login: "user@example.com"},
Password: "wrong password",
}}
responses := make([]*httptest.ResponseRecorder, 0, 2)
for _, db := range []*testAccountDB{
{result: testActiveAccount(t)},
{err: merrors.ErrNoData},
} {
router := &PublicRouter{logger: zap.NewNop(), db: db, service: mservice.Accounts}
_, failed, h := router.authenticateAccount(context.Background(), request)
if !failed {
t.Fatal("invalid credentials were not classified as a failure")
}
req, _ := loginRequest("user@example.com", "wrong password", "192.0.2.1:1000")
responses = append(responses, executeLoginHandler(t, h, req))
}
if responses[0].Code != http.StatusUnauthorized || responses[1].Code != http.StatusUnauthorized {
t.Fatalf("unexpected statuses: existing=%d unknown=%d", responses[0].Code, responses[1].Code)
}
if responses[0].Body.String() != responses[1].Body.String() ||
!strings.Contains(responses[0].Body.String(), "invalid credentials") {
t.Fatalf("credential responses differ: existing=%s unknown=%s", responses[0].Body.String(), responses[1].Body.String())
}
}
func TestLoginRateLimitIsAtomicAcrossRouterInstances(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
store := newFakeLoginAttemptStore()
db := &testAccountDB{err: merrors.ErrNoData}
limits := testLoginLimits(5, 1000, 15*time.Minute)
routers := []*PublicRouter{
testRateLimitedRouter(db, store, limits, &now),
testRateLimitedRouter(db, store, limits, &now),
}
var unauthorized atomic.Int64
var limited atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
req, login := loginRequest("shared@example.com", "wrong password", "203.0.113."+strconv.Itoa(index+1)+":9000")
_, h := routers[index%len(routers)].authenticateRateLimited(req, login)
recorder := httptest.NewRecorder()
h(recorder, req)
switch recorder.Code {
case http.StatusUnauthorized:
unauthorized.Add(1)
case http.StatusTooManyRequests:
limited.Add(1)
}
}(i)
}
wg.Wait()
if unauthorized.Load() != 4 || limited.Load() != 16 {
t.Fatalf("concurrent decisions: unauthorized=%d limited=%d", unauthorized.Load(), limited.Load())
}
}
func TestLoginRateLimitFailsClosedOnStorageError(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
store := newFakeLoginAttemptStore()
store.currentErr = merrors.Internal("database unavailable")
db := &testAccountDB{result: testActiveAccount(t)}
router := testRateLimitedRouter(db, store, testLoginLimits(5, 50, 15*time.Minute), &now)
req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.1:1000")
_, h := router.authenticateRateLimited(req, login)
recorder := executeLoginHandler(t, h, req)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected service unavailable, got %d", recorder.Code)
}
if db.getByEmailCalls != 0 {
t.Fatalf("storage failure must stop before password lookup, got %d lookups", db.getByEmailCalls)
}
if strings.Contains(recorder.Body.String(), "database unavailable") {
t.Fatal("storage cause leaked to the public response")
}
}
func TestLoginRateLimitFailsClosedWhenFailureCannotBeRecorded(t *testing.T) {
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
store := newFakeLoginAttemptStore()
store.incrementErr = merrors.Internal("write unavailable")
router := testRateLimitedRouter(
&testAccountDB{err: merrors.ErrNoData},
store,
testLoginLimits(5, 50, 15*time.Minute),
&now,
)
req, login := loginRequest("missing@example.com", "wrong password", "192.0.2.1:1000")
_, h := router.authenticateRateLimited(req, login)
recorder := executeLoginHandler(t, h, req)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected service unavailable, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "login protection is temporarily unavailable") {
t.Fatalf("unexpected public failure body: %s", recorder.Body.String())
}
}
func TestDummyPasswordHashRemainsValid(t *testing.T) {
if !(&model.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") {
t.Fatal("dummy password hash is not a valid bcrypt hash")
}
if (&model.Account{Password: dummyPasswordHash}).MatchPassword("different") {
t.Fatal("dummy password hash unexpectedly matched another password")
}
}
@@ -0,0 +1,268 @@
package public
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
func TestLoginRejectsUnknownClientID(t *testing.T) {
testRejectsUnknownClientID(t, "/login", `{
"login":{"login":"user@example.com","password":"correct horse battery staple"},
"clientId":"missing-client",
"deviceId":"device-1"
}`, 0, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
return router.login(req)
})
}
func TestAPILoginRejectsUnknownClientID(t *testing.T) {
testRejectsUnknownClientID(t, "/login/api", `{
"login":{"login":"user@example.com","password":"correct horse battery staple"},
"clientId":"missing-client",
"deviceId":"device-1",
"clientSecret":"secret"
}`, 1, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
return router.apiLogin(req)
})
}
func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) {
account := testActiveAccount(t)
unusedDB := &testAccountDB{err: merrors.ErrNoData}
activeDB := &testAccountDB{result: account}
router := &PublicRouter{
logger: zap.NewNop(),
db: activeDB,
service: mservice.Accounts,
}
got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "user@example.com",
},
Password: "correct horse battery staple",
},
})
if h != nil {
t.Fatal("expected login authentication to succeed against injected account store")
}
if failed {
t.Fatal("successful login was classified as a credential failure")
}
if got == nil || got.Login != account.Login {
t.Fatalf("expected account %q, got %#v", account.Login, got)
}
if activeDB.getByEmailCalls != 1 {
t.Fatalf("expected one active account store lookup, got %d", activeDB.getByEmailCalls)
}
if unusedDB.getByEmailCalls != 0 {
t.Fatalf("expected no unused account store lookups, got %d", unusedDB.getByEmailCalls)
}
}
func testRejectsUnknownClientID(
t *testing.T,
path string,
body string,
wantLookups int,
handler func(*PublicRouter, *http.Request) http.HandlerFunc,
) {
t.Helper()
account := testActiveAccount(t)
accountDB := &testAccountDB{result: account}
router := &PublicRouter{
logger: zap.NewNop(),
db: accountDB,
rtdb: &testRefreshTokenDB{clientErr: merrors.ErrNoData},
service: mservice.Accounts,
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(body))
rec := httptest.NewRecorder()
handler(router, req)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
if accountDB.getByEmailCalls != wantLookups {
t.Fatalf("expected %d account lookups, got %d", wantLookups, accountDB.getByEmailCalls)
}
resp := decodeErrorResponse(t, rec.Body.Bytes())
if resp.Data.Details != "unknown client id" {
t.Fatalf("expected unknown client id message, got %q", resp.Data.Details)
}
}
func TestLoginRejectsPendingVerificationAccount(t *testing.T) {
account := testAccountWithStatus(t, model.AccountPendingVerification)
router := &PublicRouter{
logger: zap.NewNop(),
db: &testAccountDB{result: account},
service: mservice.Accounts,
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
"login":{"login":"user@example.com","password":"correct horse battery staple"}
}`))
rec := httptest.NewRecorder()
router.login(req)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
resp := decodeErrorResponse(t, rec.Body.Bytes())
if resp.Data.Details != "Account verification required" {
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
}
}
func TestLoginRejectsBlockedAccount(t *testing.T) {
account := testAccountWithStatus(t, model.AccountBlocked)
router := &PublicRouter{
logger: zap.NewNop(),
db: &testAccountDB{result: account},
service: mservice.Accounts,
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
"login":{"login":"user@example.com","password":"correct horse battery staple"}
}`))
rec := httptest.NewRecorder()
router.login(req)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
resp := decodeErrorResponse(t, rec.Body.Bytes())
if resp.Data.Details != "Account verification required" {
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
}
}
func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
account := testActiveAccount(t)
clientID := "api-client"
deviceID := "device-1"
rtdb := &testRefreshTokenDB{
client: &model.Client{
ClientID: clientID,
ClientSecret: "secret",
GrantTypes: []string{"password"},
TokenEndpointAuthMethod: "client_secret_post",
AccountRef: account.GetID(),
},
}
var cfg middleware.TokenConfig
cfg.Length = 16
cfg.Expiration.Account = 1
cfg.Expiration.Refresh = 24
signature := middleware.Signature{
Algorithm: "HS256",
PrivateKey: []byte("test-secret"),
}
router := NewRouter(
zap.NewNop(),
"/",
&testAccountDB{result: account},
nil,
rtdb,
nil,
chi.NewRouter(),
&cfg,
&signature,
middleware.LoginRateLimits{},
)
body := `{
"login":{"login":"user@example.com","password":"correct horse battery staple"},
"clientId":"api-client",
"deviceId":"device-1",
"clientSecret":"secret"
}`
firstReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
firstRec := httptest.NewRecorder()
router.apiLogin(firstReq)(firstRec, firstReq)
if firstRec.Code != http.StatusOK {
t.Fatalf("expected first login status %d, got %d", http.StatusOK, firstRec.Code)
}
firstResp := decodeLoginResponse(t, firstRec.Body.Bytes())
if firstResp.Data.RefreshToken.Token == "" {
t.Fatal("expected first login to return refresh token")
}
err := rtdb.Revoke(context.Background(), *account.GetID(), &model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
})
if err != nil {
t.Fatalf("failed to revoke refresh token: %v", err)
}
secondReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
secondRec := httptest.NewRecorder()
router.apiLogin(secondReq)(secondRec, secondReq)
if secondRec.Code != http.StatusOK {
t.Fatalf("expected second login status %d, got %d", http.StatusOK, secondRec.Code)
}
secondResp := decodeLoginResponse(t, secondRec.Body.Bytes())
if secondResp.Data.RefreshToken.Token == "" {
t.Fatal("expected second login to return refresh token")
}
if secondResp.Data.RefreshToken.Token == firstResp.Data.RefreshToken.Token {
t.Fatal("expected second login to issue a different refresh token after revocation")
}
_, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: firstResp.Data.RefreshToken.Token,
})
if !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected old refresh token to be unusable, got %v", err)
}
_, err = rtdb.GetByCRT(context.Background(), &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: secondResp.Data.RefreshToken.Token,
})
if err != nil {
t.Fatalf("expected new refresh token to be active, got %v", err)
}
if got := rtdb.tokenCountForSession(*account.GetID(), clientID, deviceID); got != 2 {
t.Fatalf("expected 2 refresh token records for session, got %d", got)
}
if got := rtdb.activeTokenCountForSession(*account.GetID(), clientID, deviceID); got != 1 {
t.Fatalf("expected 1 active refresh token for session, got %d", got)
}
}
@@ -0,0 +1,288 @@
package public
import (
"context"
"encoding/json"
"sync"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type testErrorResponse struct {
Data struct {
Details string `json:"details"`
} `json:"data"`
}
type testLoginResponse struct {
Data struct {
AccessToken struct {
Token string `json:"token"`
} `json:"accessToken"`
RefreshToken struct {
Token string `json:"token"`
} `json:"refreshToken"`
} `json:"data"`
}
func decodeErrorResponse(t *testing.T, body []byte) testErrorResponse {
t.Helper()
var resp testErrorResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
return resp
}
func decodeLoginResponse(t *testing.T, body []byte) testLoginResponse {
t.Helper()
var resp testLoginResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to decode login response body: %v", err)
}
return resp
}
func testActiveAccount(t *testing.T) *model.Account {
t.Helper()
return testAccountWithStatus(t, model.AccountActive)
}
func testAccountWithStatus(t *testing.T, status model.AccountStatus) *model.Account {
t.Helper()
account := &model.Account{
AccountPublic: model.AccountPublic{
UserDataBase: model.UserDataBase{
Login: "user@example.com",
},
},
Password: "correct horse battery staple",
Status: status,
}
account.SetID(bson.NewObjectID())
if err := account.HashPassword(); err != nil {
t.Fatalf("failed to hash password: %v", err)
}
return account
}
type testAccountDB struct {
mu sync.Mutex
result *model.Account
err error
getByEmailCalls int
}
func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) {
db.mu.Lock()
defer db.mu.Unlock()
db.getByEmailCalls++
return db.result, db.err
}
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.Account) error {
if db.err != nil {
return db.err
}
if db.result == nil || db.result.GetID() == nil || *db.result.GetID() != objectRef {
return merrors.ErrNoData
}
*result = *db.result
return nil
}
func (db *testAccountDB) Update(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) Delete(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (db *testAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error {
return merrors.NotImplemented("stub")
}
type testRefreshTokenDB struct {
client *model.Client
clientErr error
tokens []*model.RefreshToken
}
func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) {
return db.client, db.clientErr
}
func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error {
for _, token := range db.tokens {
if token.AccountRef == nil || *token.AccountRef != accountRef {
continue
}
if token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked {
token.IsRevoked = true
return nil
}
}
return nil
}
func (db *testRefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) {
for _, stored := range db.tokens {
if stored.ClientID == token.ClientID && stored.DeviceID == token.DeviceID && stored.RefreshToken == token.RefreshToken && !stored.IsRevoked {
stored.IsRevoked = true
return true, nil
}
}
return false, nil
}
func (db *testRefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) {
for _, token := range db.tokens {
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked {
return cloneRefreshToken(token), nil
}
}
return nil, merrors.ErrNoData
}
func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) {
for _, token := range db.tokens {
if token.ClientID == t.ClientID && token.DeviceID == t.DeviceID && token.RefreshToken == t.RefreshToken {
if token.IsRevoked {
return nil, merrors.ErrNoData
}
return cloneRefreshToken(token), nil
}
}
return nil, merrors.ErrNoData
}
func (db *testRefreshTokenDB) Create(ctx context.Context, object *model.RefreshToken) error {
for _, token := range db.tokens {
if token.AccountRef == nil || object.AccountRef == nil {
continue
}
if *token.AccountRef == *object.AccountRef && token.ClientID == object.ClientID && token.DeviceID == object.DeviceID && !token.IsRevoked {
token.RefreshToken = object.RefreshToken
token.SessionVersion = object.SessionVersion
token.ExpiresAt = object.ExpiresAt
token.UserAgent = object.UserAgent
token.IPAddress = object.IPAddress
token.LastUsedAt = object.LastUsedAt
token.IsRevoked = object.IsRevoked
if token.GetID() == nil || token.GetID().IsZero() {
token.SetID(bson.NewObjectID())
}
object.SetID(*token.GetID())
return nil
}
}
clone := cloneRefreshToken(object)
if clone.GetID() == nil || clone.GetID().IsZero() {
clone.SetID(bson.NewObjectID())
}
object.SetID(*clone.GetID())
db.tokens = append(db.tokens, clone)
return nil
}
func (db *testRefreshTokenDB) InsertMany(ctx context.Context, objects []*model.RefreshToken) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.RefreshToken) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) Update(ctx context.Context, object *model.RefreshToken) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) Delete(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) FindOne(ctx context.Context, query builder.Query, result *model.RefreshToken) error {
return merrors.NotImplemented("stub")
}
func (db *testRefreshTokenDB) tokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int {
count := 0
for _, token := range db.tokens {
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID {
count++
}
}
return count
}
func (db *testRefreshTokenDB) activeTokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int {
count := 0
for _, token := range db.tokens {
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID && !token.IsRevoked {
count++
}
}
return count
}
func cloneRefreshToken(token *model.RefreshToken) *model.RefreshToken {
if token == nil {
return nil
}
cloned := *token
if token.AccountRef != nil {
accountRef := *token.AccountRef
cloned.AccountRef = &accountRef
}
return &cloned
}
@@ -0,0 +1,55 @@
package public
import (
"encoding/json"
"net/http"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.uber.org/zap"
)
func (pr *PublicRouter) logout(r *http.Request) http.HandlerFunc {
respond := func(handler http.HandlerFunc, clearCookie bool) http.HandlerFunc {
if !clearCookie || !browser.UsesCookie(r) {
return handler
}
return func(w http.ResponseWriter, request *http.Request) {
browser.ClearRefreshCookie(w, request)
handler.ServeHTTP(w, request)
}
}
var req srequest.AccessTokenRefresh
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Rejected malformed logout request", zap.Error(err))
return respond(response.BadPayload(pr.logger, mservice.RefreshTokens, err), false)
}
credential, err := browser.RefreshCredential(r, req.RefreshToken)
if err != nil {
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive)
return respond(response.Success(pr.logger), true)
}
req.RefreshToken = credential
if req.ClientID == "" || req.DeviceID == "" || req.RefreshToken == "" {
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.MissingIdentity)
return respond(response.BadRequest(pr.logger, pr.service, "missing_session", "refresh token and session identifier are required"), false)
}
revoked, err := pr.rtdb.RevokeToken(r.Context(), &req)
if err != nil {
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.StorageError)
pr.logger.Warn("Failed to revoke current session", zap.Error(err),
zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return respond(response.Internal(pr.logger, pr.service, err), false)
}
if !revoked {
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive)
return respond(response.Success(pr.logger), true)
}
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.Succeeded)
pr.logger.Info("Current session revoked", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return respond(response.Success(pr.logger), true)
}
@@ -0,0 +1,204 @@
package public
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type logoutDB struct {
refreshtokens.DB
mu sync.Mutex
tokens map[string]*model.RefreshToken
lookupErr error
revokeErr error
}
func (db *logoutDB) RevokeToken(_ context.Context, request *model.ClientRefreshToken) (bool, error) {
db.mu.Lock()
defer db.mu.Unlock()
if db.lookupErr != nil {
return false, db.lookupErr
}
token := db.tokens[request.DeviceID]
if token == nil || token.IsRevoked || token.RefreshToken != request.RefreshToken {
return false, nil
}
if db.revokeErr != nil {
return false, db.revokeErr
}
token.IsRevoked = true
return true, nil
}
func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *model.RefreshToken {
return &model.RefreshToken{
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID},
RefreshToken: value,
},
AccountRef: &accountRef,
ExpiresAt: time.Now().Add(time.Hour),
}
}
func executeLogout(router *PublicRouter, deviceID, refreshToken string) int {
body := `{"clientId":"web","deviceId":"` + deviceID + `","token":"` + refreshToken + `"}`
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout", strings.NewReader(body))
response := httptest.NewRecorder()
router.logout(request)(response, request)
return response.Code
}
func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) {
accountRef := bson.NewObjectID()
db := &logoutDB{tokens: map[string]*model.RefreshToken{
"device-a": newLogoutToken(accountRef, "device-a", "refresh-a"),
"device-b": newLogoutToken(accountRef, "device-b", "refresh-b"),
}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK {
t.Fatalf("first logout status = %d", status)
}
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK {
t.Fatalf("repeated logout status = %d", status)
}
if !db.tokens["device-a"].IsRevoked {
t.Fatal("current device session was not revoked")
}
if db.tokens["device-b"].IsRevoked {
t.Fatal("another device session was revoked")
}
}
func TestConcurrentLogoutIsIdempotent(t *testing.T) {
accountRef := bson.NewObjectID()
db := &logoutDB{tokens: map[string]*model.RefreshToken{
"device-a": newLogoutToken(accountRef, "device-a", "refresh-a"),
}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
statuses := make(chan int, 8)
var workers sync.WaitGroup
for range 8 {
workers.Add(1)
go func() {
defer workers.Done()
statuses <- executeLogout(router, "device-a", "refresh-a")
}()
}
workers.Wait()
close(statuses)
for status := range statuses {
if status != http.StatusOK {
t.Fatalf("concurrent logout status = %d", status)
}
}
}
func TestDelayedLogoutDoesNotRevokeReplacementSession(t *testing.T) {
accountRef := bson.NewObjectID()
db := &logoutDB{tokens: map[string]*model.RefreshToken{
"device-a": newLogoutToken(accountRef, "device-a", "new-refresh"),
}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
if status := executeLogout(router, "device-a", "old-refresh"); status != http.StatusOK {
t.Fatalf("delayed logout status = %d", status)
}
if db.tokens["device-a"].IsRevoked {
t.Fatal("replacement session was revoked by a stale logout request")
}
}
func TestLogoutAcceptsAnAlreadyExpiredSession(t *testing.T) {
accountRef := bson.NewObjectID()
expired := newLogoutToken(accountRef, "device-a", "expired-refresh")
expired.ExpiresAt = time.Now().Add(-time.Hour)
db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": expired}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
if status := executeLogout(router, "device-a", "expired-refresh"); status != http.StatusOK {
t.Fatalf("expired logout status = %d", status)
}
if !expired.IsRevoked {
t.Fatal("expired session was not marked revoked before cleanup")
}
}
func TestLogoutFailsClosedWhenSessionStoreIsUnavailable(t *testing.T) {
db := &logoutDB{lookupErr: merrors.Internal("database unavailable")}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusInternalServerError {
t.Fatalf("logout status = %d, want %d", status, http.StatusInternalServerError)
}
}
func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) {
accountRef := bson.NewObjectID()
token := newLogoutToken(accountRef, "device-a", "cookie-refresh")
db := &logoutDB{tokens: map[string]*model.RefreshToken{"device-a": token}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}`
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
response := httptest.NewRecorder()
router.logout(request)(response, request)
require.Equal(t, http.StatusOK, response.Code)
require.True(t, token.IsRevoked)
cookies := response.Result().Cookies()
require.Len(t, cookies, 1)
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
require.Equal(t, -1, cookies[0].MaxAge)
require.Equal(t, "/api/v1/accounts", cookies[0].Path)
}
func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing.T) {
db := &logoutDB{tokens: map[string]*model.RefreshToken{}}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}`
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
response := httptest.NewRecorder()
router.logout(request)(response, request)
require.Equal(t, http.StatusOK, response.Code)
require.Contains(t, response.Header().Get("Set-Cookie"), browser.RefreshCookie+"=")
require.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0")
}
func TestBrowserLogoutKeepsCookieWhenRevocationFails(t *testing.T) {
db := &logoutDB{
tokens: map[string]*model.RefreshToken{},
lookupErr: merrors.Internal("database unavailable"),
}
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
body := `{"clientId":"web","deviceId":"device-a"}`
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
response := httptest.NewRecorder()
router.logout(request)(response, request)
require.Equal(t, http.StatusInternalServerError, response.Code)
require.Empty(t, response.Header().Get("Set-Cookie"), "client must retain the cookie so logout can retry revocation")
}
@@ -0,0 +1,53 @@
package public
import (
"encoding/json"
"errors"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"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, refreshToken, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
if errors.Is(err, errClientIPNotAllowed) {
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
}
if isExpectedRefreshValidationError(err) {
pr.logger.Debug("Access token refresh rejected", zap.Error(err))
return pr.rejectRefresh(r, err)
}
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
if browser.UsesCookie(r) && refreshToken.ExpiresAt.Before(time.Now().Add(7*24*time.Hour)) {
return pr.refreshAndRespondLogin(r.Context(), r, &refreshToken.SessionIdentifier, account)
}
return sresponse.Account(pr.logger, account, token)
}
func (pr *PublicRouter) rejectRefresh(r *http.Request, err error) http.HandlerFunc {
handler := response.Auto(pr.logger, pr.service, err)
if !browser.UsesCookie(r) {
return handler
}
return func(w http.ResponseWriter, request *http.Request) {
browser.ClearRefreshCookie(w, request)
handler.ServeHTTP(w, request)
}
}
@@ -0,0 +1,137 @@
package public
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, *testRefreshTokenDB) {
t.Helper()
account := testActiveAccount(t)
clientID := "web"
deviceID := "browser-1"
rtdb := &testRefreshTokenDB{
client: &model.Client{ClientID: clientID},
tokens: []*model.RefreshToken{
{
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
SessionVersion: "version-1",
},
RefreshToken: "cookie-refresh",
},
AccountRef: account.GetID(),
ExpiresAt: expiresAt,
},
},
}
config := middleware.TokenConfig{
Expiration: middleware.TokenExpiration{Account: 1, Refresh: 720},
Length: 32,
}
signature := middleware.Signature{
Algorithm: "HS256",
PrivateKey: []byte("browser-refresh-test-secret"),
}
return NewRouter(
zap.NewNop(),
"/api/v1",
&testAccountDB{result: account},
nil,
rtdb,
nil,
chi.NewRouter(),
&config,
&signature,
middleware.LoginRateLimits{},
), rtdb
}
func executeBrowserRefresh(t *testing.T, router *PublicRouter) *httptest.ResponseRecorder {
t.Helper()
body := `{"clientId":"web","deviceId":"browser-1","token":"body-must-be-ignored"}`
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body))
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
response := httptest.NewRecorder()
router.refreshAccessToken(request)(response, request)
return response
}
func executeNativeRefresh(t *testing.T, router *PublicRouter, token string) *httptest.ResponseRecorder {
t.Helper()
body := `{"clientId":"web","deviceId":"browser-1","token":"` + token + `"}`
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body))
response := httptest.NewRecorder()
router.refreshAccessToken(request)(response, request)
return response
}
func TestBrowserRefreshRestoresAccessWithoutExposingRefreshCredential(t *testing.T) {
router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
response := executeBrowserRefresh(t, router)
require.Equal(t, http.StatusOK, response.Code)
require.NotContains(t, response.Body.String(), "cookie-refresh")
require.NotEmpty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.AccessToken.Token)
require.Empty(t, response.Header().Get("Set-Cookie"))
}
func TestBrowserRefreshRotatesExpiringCookieServerSide(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
response := executeBrowserRefresh(t, router)
require.Equal(t, http.StatusOK, response.Code)
require.NotContains(t, response.Body.String(), "cookie-refresh")
cookies := response.Result().Cookies()
require.Len(t, cookies, 1)
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
require.NotEqual(t, "cookie-refresh", cookies[0].Value)
require.Empty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.RefreshToken.Token)
require.Equal(t, cookies[0].Value, rtdb.tokens[0].RefreshToken)
}
func TestBrowserRefreshRejectsStaleCookieAndClearsIt(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
rtdb.tokens = nil
response := executeBrowserRefresh(t, router)
require.Equal(t, http.StatusUnauthorized, response.Code)
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
require.NotContains(t, response.Body.String(), "cookie-refresh")
cookies := response.Result().Cookies()
require.Len(t, cookies, 1)
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
require.Empty(t, cookies[0].Value)
require.Equal(t, -1, cookies[0].MaxAge)
require.True(t, cookies[0].HttpOnly)
require.True(t, cookies[0].Secure)
}
func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
response := executeNativeRefresh(t, router, "stale-refresh")
require.Equal(t, http.StatusUnauthorized, response.Code)
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
require.Empty(t, response.Header().Get("Set-Cookie"))
}
@@ -0,0 +1,48 @@
package public
import (
"context"
"net/http"
rtokens "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/tokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.uber.org/zap"
)
func (pr *PublicRouter) refreshAndRespondLogin(
ctx context.Context,
r *http.Request,
session *model.SessionIdentifier,
account *model.Account,
) 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)
}
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
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)
}
@@ -0,0 +1,36 @@
package public
import (
"encoding/json"
"errors"
"net/http"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"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, _, _, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
if errors.Is(err, errClientIPNotAllowed) {
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
}
if isExpectedRefreshValidationError(err) {
pr.logger.Debug("Refresh token rotation rejected", zap.Error(err))
return pr.rejectRefresh(r, err)
}
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)
}
@@ -0,0 +1,69 @@
package public
import (
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
signature middleware.Signature
service mservice.Type
attempts ratelimits.Store
limits middleware.LoginRateLimits
now func() time.Time
}
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,
attempts ratelimits.Store,
router chi.Router,
config *middleware.TokenConfig,
signature *middleware.Signature,
limits middleware.LoginRateLimits,
) *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,
attempts: attempts,
limits: limits,
now: time.Now,
}
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
hr.InstallHandler(hr.service, "/logout", api.Post, hr.logout)
return &hr
}
@@ -0,0 +1,98 @@
package public
import (
"context"
"errors"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.uber.org/zap"
)
var errClientIPNotAllowed = merrors.Define("client_ip_not_allowed")
func isExpectedRefreshValidationError(err error) bool {
return errors.Is(err, merrors.ErrUnauthorized)
}
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, r *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, *model.RefreshToken, error) {
credential, err := browser.RefreshCredential(r, req.RefreshToken)
if err != nil {
return nil, nil, nil, err
}
req.RefreshToken = credential
client, err := pr.rtdb.GetClient(ctx, req.ClientID)
if errors.Is(err, merrors.ErrNoData) || client == nil {
pr.logger.Debug("Refresh token rejected: client not found", zap.String("client_id", req.ClientID))
return nil, nil, nil, merrors.Unauthorized("client not found")
}
if err != nil {
pr.logger.Warn("Failed to fetch client for refresh token validation", zap.Error(err), zap.String("client_id", req.ClientID))
return nil, nil, nil, err
}
if client.IsRevoked {
return nil, nil, nil, merrors.Unauthorized("client has been revoked")
}
if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil {
return nil, nil, nil, errClientIPNotAllowed
}
rt, err := pr.rtdb.GetByCRT(ctx, req)
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) {
pr.logger.Debug("Refresh session is inactive", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return nil, nil, nil, merrors.Unauthorized("refresh session is inactive")
}
if err != nil {
return nil, nil, nil, err
}
if rt.SessionVersion == "" {
pr.logger.Debug("Refresh token rejected: legacy session requires reauthentication",
zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return nil, nil, nil, merrors.Unauthorized("session reauthentication required")
}
if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 {
pr.logger.Debug("Token validation failed", zap.String("reason", reason))
return nil, 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.Debug("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
return nil, nil, nil, merrors.Unauthorized("user not found")
} else if err != nil {
pr.logger.Warn("Failed to fetch user while validating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
return nil, nil, nil, err
}
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return nil, nil, nil, err
}
return &account, &accessToken, rt, nil
}
@@ -0,0 +1,16 @@
package router
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
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)
}
@@ -0,0 +1,68 @@
package websocket
import (
"context"
"fmt"
"net/http"
"os"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"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) {
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
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 *ws.Config, 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
}
@@ -0,0 +1,15 @@
package websocket
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
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)
}