Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user