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

+121
View File
@@ -0,0 +1,121 @@
package browser
import (
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
const (
TransportHeader = "X-Auth-Transport"
TransportCookie = "cookie"
CSRFHeader = "X-CSRF-Protection"
CSRFValue = "1"
RefreshCookie = "sxchnge_refresh"
)
func UsesCookie(r *http.Request) bool {
return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie)
}
func RefreshCredential(r *http.Request, fallback string) (string, error) {
if !UsesCookie(r) {
return strings.TrimSpace(fallback), nil
}
cookie, err := r.Cookie(RefreshCookie)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return "", merrors.Unauthorized("browser session is missing")
}
return cookie.Value, nil
}
func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
if !UsesCookie(r) {
return
}
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: token,
Path: cookiePath(r),
Expires: expiresAt.UTC(),
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) {
if !UsesCookie(r) {
return
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: "",
Path: cookiePath(r),
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
if !UsesCookie(r) || isSafeMethod(r.Method) {
return nil
}
if r.Header.Get(CSRFHeader) != CSRFValue {
return merrors.PermissionDenied("browser CSRF protection header is missing")
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originAllowed(origin, allowedOrigins) {
return merrors.PermissionDenied("browser request origin is not allowed")
}
return nil
}
func cookiePath(r *http.Request) string {
const accountsSegment = "/accounts"
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
return path.Join("/", apiEndpoint, accountsSegment)
}
if r != nil && r.URL != nil {
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
return r.URL.Path[:index+len(accountsSegment)]
}
}
return accountsSegment
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
func originAllowed(origin string, allowedOrigins []string) bool {
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" {
return false
}
normalized := strings.TrimSuffix(strings.ToLower(origin), "/")
for _, allowed := range allowedOrigins {
if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized {
return true
}
}
return false
}
@@ -0,0 +1,122 @@
package browser
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
req.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "secret-refresh-token", time.Now().Add(time.Hour))
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Equal(t, "secret-refresh-token", cookie.Value)
require.Equal(t, "/api/v1/accounts", cookie.Path)
require.Empty(t, cookie.Domain, "an omitted Domain attribute keeps the cookie host-only")
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
require.Positive(t, cookie.MaxAge)
}
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
t.Setenv("API_ENDPOINT", "/gateway/v2/")
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
}
func TestRefreshCookieIsAlwaysSecure(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
tests := []struct {
name string
target string
forwardedProto string
}{
{name: "public hostname", target: "http://app.sendico.io/api/v1/accounts/login"},
{name: "localhost", target: "http://localhost:4000/api/v1/accounts/login"},
{name: "loopback IP", target: "http://127.0.0.1:4000/api/v1/accounts/login"},
{name: "private IP", target: "http://192.168.1.20:4000/api/v1/accounts/login"},
{name: "forwarded HTTP", target: "http://app.sendico.io/api/v1/accounts/login", forwardedProto: "http"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, test.target, nil)
req.Header.Set(TransportHeader, TransportCookie)
if test.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", test.forwardedProto)
}
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.True(t, recorder.Result().Cookies()[0].Secure)
})
}
}
func TestClearRefreshCookieUsesHardenedPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://192.168.1.20:4000/api/v1/accounts/logout", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
ClearRefreshCookie(recorder, req)
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Empty(t, cookie.Value)
require.Equal(t, -1, cookie.MaxAge)
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
}
func TestValidateCSRFRejectsCrossSiteAndMissingHeaderRequests(t *testing.T) {
allowed := []string{"https://app.sendico.io"}
missingHeader := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
missingHeader.Header.Set(TransportHeader, TransportCookie)
missingHeader.Header.Set("Origin", allowed[0])
require.Error(t, ValidateCSRF(missingHeader, allowed))
crossSite := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
crossSite.Header.Set(TransportHeader, TransportCookie)
crossSite.Header.Set(CSRFHeader, CSRFValue)
crossSite.Header.Set("Origin", "https://attacker.example")
require.Error(t, ValidateCSRF(crossSite, allowed))
valid := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
valid.Header.Set(TransportHeader, TransportCookie)
valid.Header.Set(CSRFHeader, CSRFValue)
valid.Header.Set("Origin", allowed[0])
require.NoError(t, ValidateCSRF(valid, allowed))
}
func TestRefreshCredentialNeverFallsBackForCookieTransport(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", nil)
req.Header.Set(TransportHeader, TransportCookie)
credential, err := RefreshCredential(req, "body-token")
require.Error(t, err)
require.Empty(t, credential)
}
+29
View File
@@ -0,0 +1,29 @@
package client
import (
"context"
"errors"
"strings"
"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"
)
var ErrUnknownClientID = merrors.Define("unknown client id")
func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*model.Client, error) {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return nil, ErrUnknownClientID
}
client, err := rtdb.GetClient(ctx, clientID)
if errors.Is(err, merrors.ErrNoData) || client == nil {
return nil, ErrUnknownClientID
}
if err != nil {
return nil, err
}
return client, nil
}
@@ -0,0 +1,53 @@
package client
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type resolveClientDB struct {
refreshtokens.DB
calls int
client *model.Client
err error
}
func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) {
db.calls++
return db.client, db.err
}
func TestResolveRejectsBlankClientIDWithoutStorageLookup(t *testing.T) {
db := &resolveClientDB{}
client, err := Resolve(context.Background(), db, " ")
if !errors.Is(err, ErrUnknownClientID) {
t.Fatalf("expected unknown client id, got %v", err)
}
if client != nil {
t.Fatalf("expected nil client, got %#v", client)
}
if db.calls != 0 {
t.Fatalf("expected no storage lookup for blank client id, got %d", db.calls)
}
}
func TestResolveTrimsClientID(t *testing.T) {
want := &model.Client{ClientID: "client-1"}
db := &resolveClientDB{client: want}
client, err := Resolve(context.Background(), db, " client-1 ")
if err != nil {
t.Fatalf("expected client resolution to succeed, got %v", err)
}
if client != want {
t.Fatalf("expected resolved client %#v, got %#v", want, client)
}
if db.calls != 1 {
t.Fatalf("expected one storage lookup, got %d", db.calls)
}
}
@@ -0,0 +1,35 @@
package sessionmetrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type Operation string
const (
Validation Operation = "validation"
CurrentLogout Operation = "current_logout"
AllLogout Operation = "all_logout"
)
type Outcome string
const (
Active Outcome = "active"
Succeeded Outcome = "succeeded"
AlreadyInactive Outcome = "already_inactive"
MissingIdentity Outcome = "missing_identity"
StorageError Outcome = "storage_error"
)
var lifecycle = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "auth",
Name: "session_lifecycle_total",
Help: "Session validation and logout outcomes.",
}, []string{"operation", "outcome"})
func Observe(operation Operation, outcome Outcome) {
lifecycle.WithLabelValues(string(operation), string(outcome)).Inc()
}
+148
View File
@@ -0,0 +1,148 @@
package storage
import (
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"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"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"go.uber.org/zap"
)
type Stores struct {
Accounts account.DB
Verification verification.DB
RefreshTokens refreshtokens.DB
LoginAttempts ratelimits.Store
}
type TokenStores struct {
Verification verification.DB
RefreshTokens refreshtokens.DB
}
type resolvedConfig struct {
deps *edgemongo.Dependencies
storage edgeapi.AuthStorageConfig
}
func OpenStores(
logger mlogger.Logger,
dbf db.Factory,
config *edgeapi.AuthStorageConfig,
) (*Stores, error) {
resolved, err := resolve(logger, dbf, config)
if err != nil {
return nil, err
}
logger.Info(
"Auth storage configured",
zap.String("accounts_collection", resolved.storage.AccountsCollection),
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
zap.String("clients_collection", resolved.storage.ClientsCollection),
zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection),
zap.String("login_attempts_collection", resolved.storage.LoginAttemptsCollection),
zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection),
)
adb, err := dbf.NewAccountDBWithCollection(resolved.storage.AccountsCollection)
if err != nil {
logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", resolved.storage.AccountsCollection))
return nil, err
}
tokens, err := openTokenStores(logger, resolved)
if err != nil {
return nil, err
}
attempts, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.LoginAttemptsCollection)
if err != nil {
logger.Error("Failed to create login attempt database", zap.Error(err), zap.String("collection", resolved.storage.LoginAttemptsCollection))
return nil, err
}
return &Stores{
Accounts: adb,
Verification: tokens.Verification,
RefreshTokens: tokens.RefreshTokens,
LoginAttempts: attempts,
}, nil
}
func OpenTokenStores(
logger mlogger.Logger,
dbf db.Factory,
config *edgeapi.AuthStorageConfig,
) (*TokenStores, error) {
resolved, err := resolve(logger, dbf, config)
if err != nil {
return nil, err
}
logger.Info(
"Auth token storage configured",
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
zap.String("clients_collection", resolved.storage.ClientsCollection),
zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection),
zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection),
)
return openTokenStores(logger, resolved)
}
func resolve(
logger mlogger.Logger,
dbf db.Factory,
config *edgeapi.AuthStorageConfig,
) (*resolvedConfig, error) {
deps, err := edgemongo.ResolveDependencies(dbf)
if err != nil {
logger.Error("Failed to resolve local auth storage dependencies", zap.Error(err))
return nil, err
}
return &resolvedConfig{
deps: deps,
storage: config.WithDefaults(),
}, nil
}
func openTokenStores(logger mlogger.Logger, resolved *resolvedConfig) (*TokenStores, error) {
rtdb, err := refreshtokens.CreateWithCollections(logger, resolved.deps.Database, refreshtokens.Collections{
RefreshTokens: resolved.storage.RefreshTokensCollection,
Clients: resolved.storage.ClientsCollection,
})
if err != nil {
logger.Error(
"Failed to create refresh token management database",
zap.Error(err),
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
zap.String("clients_collection", resolved.storage.ClientsCollection),
)
return nil, err
}
verificationLimits, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.VerificationRateLimitsCollection)
if err != nil {
logger.Error("Failed to create verification rate-limit database", zap.Error(err), zap.String("collection", resolved.storage.VerificationRateLimitsCollection))
return nil, err
}
cdb, err := verification.CreateWithCollection(
logger,
resolved.deps.Database,
resolved.deps.Transactions,
resolved.storage.VerificationTokensCollection,
)
if err != nil {
logger.Error("Failed to create verification token database", zap.Error(err), zap.String("collection", resolved.storage.VerificationTokensCollection))
return nil, err
}
cdb.WithIssuanceLimits(verificationLimits)
return &TokenStores{
Verification: cdb,
RefreshTokens: rtdb,
}, nil
}
@@ -0,0 +1,102 @@
package tokens
import (
"context"
"net/http"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type recordingRefreshTokenDB struct {
refreshtokens.DB
token *model.RefreshToken
}
func (db *recordingRefreshTokenDB) Create(_ context.Context, token *model.RefreshToken) error {
db.token = token
return nil
}
func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
realIP string
want string
}{
{
name: "proxied IPv4",
remoteAddr: "172.19.0.37:45706",
realIP: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "direct IPv4",
remoteAddr: "203.0.113.11:45706",
want: "203.0.113.11",
},
{
name: "proxied IPv6",
remoteAddr: "172.19.0.37:45706",
realIP: "2001:0db8:0000:0000:0000:0000:0000:0012",
want: "2001:db8::12",
},
{
name: "direct IPv6",
remoteAddr: "[2001:db8::13]:45706",
want: "2001:db8::13",
},
{
name: "missing address",
want: "",
},
{
name: "invalid address",
remoteAddr: "not-an-address",
realIP: "also-not-an-address",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := &http.Request{
RemoteAddr: tt.remoteAddr,
Header: make(http.Header),
}
if tt.realIP != "" {
request.Header.Set("X-Real-IP", tt.realIP)
}
account := &model.Account{}
account.SetID(bson.NewObjectID())
db := &recordingRefreshTokenDB{}
_, err := PrepareRefreshToken(
context.Background(),
request,
db,
32,
24,
&model.SessionIdentifier{ClientID: "client", DeviceID: "device"},
account,
zap.NewNop(),
)
if err != nil {
t.Fatalf("prepare refresh token: %v", err)
}
if db.token == nil {
t.Fatal("expected refresh token to be stored")
}
if db.token.IPAddress != tt.want {
t.Fatalf("unexpected stored IP address: got %q, want %q", db.token.IPAddress, tt.want)
}
if db.token.SessionVersion == "" {
t.Fatal("expected a server-generated session version")
}
})
}
}
+77
View File
@@ -0,0 +1,77 @@
package tokens
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
"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/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.InternalWrap(err, "failed to generate secure random bytes")
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func PrepareRefreshToken(
ctx context.Context,
r *http.Request,
rtdb refreshtokens.DB,
length int,
refreshExpiration int,
session *model.SessionIdentifier,
account *model.Account,
logger mlogger.Logger,
) (*model.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(length)
if err != nil {
logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
sessionVersion, err := generateRefreshTokenData(16)
if err != nil {
logger.Warn("Failed to generate session version", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
versionedSession := *session
versionedSession.SessionVersion = sessionVersion
clientIP := ipguard.ClientIP(r)
ipAddress := ""
if clientIP != nil {
ipAddress = clientIP.String()
}
token := &model.RefreshToken{
AccountRef: account.GetID(),
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: versionedSession,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(refreshExpiration) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: ipAddress,
}
if err = rtdb.Create(ctx, token); err != nil {
logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}